diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ca21637..6fef529 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { Command } from 'commander'; import { cleanCommand } from './commands/clean.js'; +import { compactCommand } from './commands/compact.js'; import { exploreCommand } from './commands/explore.js'; import { ghCommand } from './commands/gh.js'; import { indexCommand } from './commands/index.js'; @@ -28,6 +29,7 @@ program.addCommand(planCommand); program.addCommand(ghCommand); program.addCommand(updateCommand); program.addCommand(statsCommand); +program.addCommand(compactCommand); program.addCommand(cleanCommand); // Show help if no command provided diff --git a/packages/cli/src/commands/compact.ts b/packages/cli/src/commands/compact.ts new file mode 100644 index 0000000..b1133de --- /dev/null +++ b/packages/cli/src/commands/compact.ts @@ -0,0 +1,84 @@ +import { RepositoryIndexer } from '@lytics/dev-agent-core'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import ora from 'ora'; +import { loadConfig } from '../utils/config.js'; +import { logger } from '../utils/logger.js'; + +export const compactCommand = new Command('compact') + .description('šŸ—œļø Optimize and compact the vector store') + .option('-v, --verbose', 'Show detailed optimization information', false) + .action(async (options) => { + const spinner = ora('Loading configuration...').start(); + + try { + // Load config + const config = await loadConfig(); + if (!config) { + spinner.fail('No config found'); + logger.error('Run "dev init" first to initialize the repository'); + process.exit(1); + return; + } + + spinner.text = 'Initializing indexer...'; + const indexer = new RepositoryIndexer(config); + await indexer.initialize(); + + // Get stats before optimization + const statsBefore = await indexer.getStats(); + if (!statsBefore) { + spinner.fail('No index found'); + logger.error('Run "dev index" first to index the repository'); + await indexer.close(); + process.exit(1); + return; + } + + spinner.text = 'Optimizing vector store...'; + const startTime = Date.now(); + + // Access the internal vector storage and call optimize + // We need to access the private vectorStorage property + // @ts-expect-error - accessing private property for optimization + await indexer.vectorStorage.optimize(); + + const duration = ((Date.now() - startTime) / 1000).toFixed(2); + + // Get stats after optimization + const statsAfter = await indexer.getStats(); + + await indexer.close(); + + spinner.succeed(chalk.green('Vector store optimized successfully!')); + + // Show results + logger.log(''); + logger.log(chalk.bold('Optimization Results:')); + logger.log(` ${chalk.cyan('Duration:')} ${duration}s`); + logger.log(` ${chalk.cyan('Total documents:')} ${statsAfter?.vectorsStored || 0}`); + + if (options.verbose) { + logger.log(''); + logger.log(chalk.bold('Before Optimization:')); + logger.log(` ${chalk.cyan('Storage size:')} ${statsBefore.vectorsStored} vectors`); + logger.log(''); + logger.log(chalk.bold('After Optimization:')); + logger.log(` ${chalk.cyan('Storage size:')} ${statsAfter?.vectorsStored || 0} vectors`); + } + + logger.log(''); + logger.log( + chalk.gray( + 'Optimization merges small data fragments, updates indices, and improves query performance.' + ) + ); + } catch (error) { + spinner.fail('Failed to optimize vector store'); + logger.error(error instanceof Error ? error.message : String(error)); + if (options.verbose && error instanceof Error && error.stack) { + logger.debug(error.stack); + } + process.exit(1); + } + }); diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts index 78f8122..99a1f6d 100644 --- a/packages/cli/src/commands/stats.ts +++ b/packages/cli/src/commands/stats.ts @@ -1,4 +1,7 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { GitHubIndexer } from '@lytics/dev-agent-subagents'; import chalk from 'chalk'; import { Command } from 'commander'; import ora from 'ora'; @@ -25,6 +28,36 @@ export const statsCommand = new Command('stats') await indexer.initialize(); const stats = await indexer.getStats(); + + // Try to load GitHub stats + let githubStats = null; + try { + // Try to load repository from state file + let repository: string | undefined; + const statePath = path.join(config.repositoryPath, '.dev-agent/github-state.json'); + try { + const stateContent = await fs.readFile(statePath, 'utf-8'); + const state = JSON.parse(stateContent); + repository = state.repository; + } catch { + // State file doesn't exist + } + + const githubIndexer = new GitHubIndexer( + { + vectorStorePath: `${config.vectorStorePath}-github`, + statePath, + autoUpdate: false, + }, + repository + ); + await githubIndexer.initialize(); + githubStats = githubIndexer.getStats(); + await githubIndexer.close(); + } catch { + // GitHub not indexed, ignore + } + await indexer.close(); spinner.stop(); @@ -66,6 +99,36 @@ export const statsCommand = new Command('stats') logger.warn(`${stats.errors.length} error(s) during last indexing`); } + // Display GitHub stats if available + if (githubStats) { + logger.log(''); + logger.log(chalk.bold.cyan('šŸ”— GitHub Integration')); + logger.log(''); + logger.log(`${chalk.cyan('Repository:')} ${githubStats.repository}`); + logger.log(`${chalk.cyan('Total Documents:')} ${githubStats.totalDocuments}`); + logger.log(`${chalk.cyan('Issues:')} ${githubStats.byType.issue || 0}`); + logger.log(`${chalk.cyan('Pull Requests:')} ${githubStats.byType.pull_request || 0}`); + logger.log(''); + logger.log(`${chalk.cyan('Open:')} ${githubStats.byState.open || 0}`); + logger.log(`${chalk.cyan('Closed:')} ${githubStats.byState.closed || 0}`); + if (githubStats.byState.merged) { + logger.log(`${chalk.cyan('Merged:')} ${githubStats.byState.merged}`); + } + logger.log(''); + logger.log( + `${chalk.cyan('Last Synced:')} ${new Date(githubStats.lastIndexed).toLocaleString()}` + ); + } else { + logger.log(''); + logger.log(chalk.bold.cyan('šŸ”— GitHub Integration')); + logger.log(''); + logger.log( + chalk.gray('Not indexed. Run') + + chalk.yellow(' dev gh index ') + + chalk.gray('to sync GitHub data.') + ); + } + logger.log(''); } catch (error) { spinner.fail('Failed to load statistics'); diff --git a/packages/core/src/vector/index.ts b/packages/core/src/vector/index.ts index ac4b94b..c1f72ca 100644 --- a/packages/core/src/vector/index.ts +++ b/packages/core/src/vector/index.ts @@ -132,6 +132,18 @@ export class VectorStorage { }; } + /** + * Optimize the vector store (compact fragments, update indices) + * Call this after bulk indexing operations for better performance + */ + async optimize(): Promise { + if (!this.initialized) { + throw new Error('VectorStorage not initialized. Call initialize() first.'); + } + + await this.store.optimize(); + } + /** * Close the storage */ diff --git a/packages/core/src/vector/store.ts b/packages/core/src/vector/store.ts index 040248f..55ab63e 100644 --- a/packages/core/src/vector/store.ts +++ b/packages/core/src/vector/store.ts @@ -43,7 +43,7 @@ export class LanceDBVectorStore implements VectorStore { } /** - * Add documents to the store + * Add documents to the store using upsert (prevents duplicates) */ async add(documents: EmbeddingDocument[], embeddings: number[][]): Promise { if (!this.connection) { @@ -70,9 +70,16 @@ export class LanceDBVectorStore implements VectorStore { if (!this.table) { // Create table on first add this.table = await this.connection.createTable(this.tableName, data); + // Create scalar index on 'id' column for fast upsert operations + await this.ensureIdIndex(); } else { - // Add to existing table - await this.table.add(data); + // Use mergeInsert to prevent duplicates (upsert operation) + // This updates existing documents with the same ID or inserts new ones + await this.table + .mergeInsert('id') + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .execute(data); } } catch (error) { throw new Error( @@ -192,6 +199,44 @@ export class LanceDBVectorStore implements VectorStore { } } + /** + * Optimize the vector store (compact fragments, update indices) + */ + async optimize(): Promise { + if (!this.table) { + return; + } + + try { + await this.table.optimize(); + } catch (error) { + throw new Error( + `Failed to optimize: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Ensure scalar index exists on 'id' column for fast upsert operations + */ + private async ensureIdIndex(): Promise { + if (!this.table) { + return; + } + + try { + // Create a scalar index on the 'id' column to speed up mergeInsert operations + // LanceDB will use an appropriate index type automatically + await this.table.createIndex('id'); + } catch (error) { + // Index may already exist or not be supported - log but don't fail + // Some versions of LanceDB may not support this or it may already exist + console.warn( + `Could not create index on 'id' column: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + /** * Close the store */ diff --git a/packages/core/src/vector/types.ts b/packages/core/src/vector/types.ts index bb1b984..dc5994e 100644 --- a/packages/core/src/vector/types.ts +++ b/packages/core/src/vector/types.ts @@ -90,6 +90,11 @@ export interface VectorStore { */ count(): Promise; + /** + * Optimize the store (compact fragments, update indices) + */ + optimize(): Promise; + /** * Close the store */ diff --git a/packages/mcp-server/bin/dev-agent-mcp.ts b/packages/mcp-server/bin/dev-agent-mcp.ts index eb631d2..b136e65 100644 --- a/packages/mcp-server/bin/dev-agent-mcp.ts +++ b/packages/mcp-server/bin/dev-agent-mcp.ts @@ -5,7 +5,13 @@ */ import { RepositoryIndexer } from '@lytics/dev-agent-core'; -import { PlanAdapter, SearchAdapter, StatusAdapter } from '../src/adapters/built-in'; +import { + ExploreAdapter, + GitHubAdapter, + PlanAdapter, + SearchAdapter, + StatusAdapter, +} from '../src/adapters/built-in'; import { MCPServer } from '../src/server/mcp-server'; // Get config from environment @@ -45,6 +51,23 @@ async function main() { timeout: 60000, // 60 seconds }); + const exploreAdapter = new ExploreAdapter({ + repositoryPath, + repositoryIndexer: indexer, + defaultLimit: 10, + defaultThreshold: 0.7, + defaultFormat: 'compact', + }); + + const githubAdapter = new GitHubAdapter({ + repositoryPath, + // GitHubIndexer will be lazily initialized on first use + vectorStorePath: `${vectorStorePath}-github`, + statePath: `${repositoryPath}/.dev-agent/github-state.json`, + defaultLimit: 10, + defaultFormat: 'compact', + }); + // Create MCP server const server = new MCPServer({ serverInfo: { @@ -56,13 +79,17 @@ async function main() { logLevel, }, transport: 'stdio', - adapters: [searchAdapter, statusAdapter, planAdapter], + adapters: [searchAdapter, statusAdapter, planAdapter, exploreAdapter, githubAdapter], }); // Handle graceful shutdown const shutdown = async () => { await server.stop(); await indexer.close(); + // Close GitHub adapter if initialized + if (githubAdapter.githubIndexer) { + await githubAdapter.githubIndexer.close(); + } process.exit(0); }; diff --git a/packages/mcp-server/src/adapters/__tests__/explore-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/explore-adapter.test.ts new file mode 100644 index 0000000..2c4f47e --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/explore-adapter.test.ts @@ -0,0 +1,419 @@ +/** + * ExploreAdapter Unit Tests + */ + +import type { RepositoryIndexer, SearchResult } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ExploreAdapter } from '../built-in/explore-adapter'; +import type { ToolExecutionContext } from '../types'; + +describe('ExploreAdapter', () => { + let adapter: ExploreAdapter; + let mockIndexer: RepositoryIndexer; + let mockContext: ToolExecutionContext; + + beforeEach(() => { + // Mock RepositoryIndexer + mockIndexer = { + search: vi.fn(), + } as unknown as RepositoryIndexer; + + // Create adapter + adapter = new ExploreAdapter({ + repositoryPath: '/test/repo', + repositoryIndexer: mockIndexer, + defaultLimit: 10, + defaultThreshold: 0.7, + defaultFormat: 'compact', + }); + + // Mock execution context + mockContext = { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + } as unknown as ToolExecutionContext; + }); + + describe('Tool Definition', () => { + it('should return correct tool definition', () => { + const definition = adapter.getToolDefinition(); + + expect(definition.name).toBe('dev_explore'); + expect(definition.description).toContain('semantic search'); + expect(definition.inputSchema.required).toEqual(['action', 'query']); + expect(definition.inputSchema.properties.action.enum).toEqual([ + 'pattern', + 'similar', + 'relationships', + ]); + }); + }); + + describe('Input Validation', () => { + it('should reject invalid action', async () => { + const result = await adapter.execute( + { + action: 'invalid', + query: 'test', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_ACTION'); + }); + + it('should reject empty query', async () => { + const result = await adapter.execute( + { + action: 'pattern', + query: '', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_QUERY'); + }); + + it('should reject invalid limit', async () => { + const result = await adapter.execute( + { + action: 'pattern', + query: 'test', + limit: 0, + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_LIMIT'); + }); + + it('should reject invalid threshold', async () => { + const result = await adapter.execute( + { + action: 'pattern', + query: 'test', + threshold: 1.5, + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_THRESHOLD'); + }); + + it('should reject invalid format', async () => { + const result = await adapter.execute( + { + action: 'pattern', + query: 'test', + format: 'invalid', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_FORMAT'); + }); + }); + + describe('Pattern Search', () => { + it('should search for patterns in compact format', async () => { + const mockResults: SearchResult[] = [ + { + id: '1', + score: 0.9, + metadata: { + path: 'src/auth.ts', + type: 'function', + name: 'authenticate', + }, + }, + { + id: '2', + score: 0.8, + metadata: { + path: 'src/middleware/auth.ts', + type: 'function', + name: 'checkAuth', + }, + }, + ]; + + vi.mocked(mockIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'pattern', + query: 'authentication', + format: 'compact', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('Pattern Search Results'); + expect((result.data as { content: string })?.content).toContain('authenticate'); + expect((result.data as { content: string })?.content).toContain('src/auth.ts'); + }); + + it('should search for patterns in verbose format', async () => { + const mockResults: SearchResult[] = [ + { + id: '1', + score: 0.9, + metadata: { + path: 'src/auth.ts', + type: 'function', + name: 'authenticate', + startLine: 10, + endLine: 20, + }, + }, + ]; + + vi.mocked(mockIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'pattern', + query: 'authentication', + format: 'verbose', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('### authenticate'); + expect((result.data as { content: string })?.content).toContain('**Lines:** 10-20'); + }); + + it('should filter by file types', async () => { + const mockResults: SearchResult[] = [ + { + id: '1', + score: 0.9, + metadata: { + path: 'src/auth.ts', + type: 'function', + name: 'authenticate', + }, + }, + { + id: '2', + score: 0.8, + metadata: { + path: 'src/auth.js', + type: 'function', + name: 'checkAuth', + }, + }, + ]; + + vi.mocked(mockIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'pattern', + query: 'authentication', + fileTypes: ['.ts'], + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('src/auth.ts'); + expect((result.data as { content: string })?.content).not.toContain('src/auth.js'); + }); + + it('should handle no results found', async () => { + vi.mocked(mockIndexer.search).mockResolvedValue([]); + + const result = await adapter.execute( + { + action: 'pattern', + query: 'nonexistent', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('No matching patterns found'); + }); + }); + + describe('Similar Code Search', () => { + it('should find similar code in compact format', async () => { + const mockResults: SearchResult[] = [ + { + id: '1', + score: 1.0, + metadata: { + path: 'src/auth.ts', // Reference file itself + type: 'file', + name: 'auth.ts', + }, + }, + { + id: '2', + score: 0.85, + metadata: { + path: 'src/auth-utils.ts', + type: 'file', + name: 'auth-utils.ts', + }, + }, + ]; + + vi.mocked(mockIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'similar', + query: 'src/auth.ts', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('Similar Code'); + expect((result.data as { content: string })?.content).toContain('src/auth-utils.ts'); + // Note: Reference file path appears in header but not in results list + expect((result.data as { content: string })?.content).toContain( + '**Reference:** `src/auth.ts`' + ); + }); + + it('should handle no similar files', async () => { + vi.mocked(mockIndexer.search).mockResolvedValue([ + { + id: '1', + score: 1.0, + metadata: { + path: 'src/unique.ts', + type: 'file', + name: 'unique.ts', + }, + }, + ]); + + const result = await adapter.execute( + { + action: 'similar', + query: 'src/unique.ts', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('No similar code found'); + }); + }); + + describe('Relationships', () => { + it('should find relationships in compact format', async () => { + const mockResults: SearchResult[] = [ + { + id: '1', + score: 0.8, + metadata: { + path: 'src/app.ts', + type: 'import', + name: 'import statement', + }, + }, + { + id: '2', + score: 0.75, + metadata: { + path: 'src/routes.ts', + type: 'import', + name: 'import statement', + }, + }, + ]; + + vi.mocked(mockIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'relationships', + query: 'src/auth.ts', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('Code Relationships'); + expect((result.data as { content: string })?.content).toContain('src/app.ts'); + }); + + it('should handle no relationships found', async () => { + vi.mocked(mockIndexer.search).mockResolvedValue([]); + + const result = await adapter.execute( + { + action: 'relationships', + query: 'src/isolated.ts', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('No relationships found'); + }); + }); + + describe('Error Handling', () => { + it('should handle file not found errors', async () => { + vi.mocked(mockIndexer.search).mockRejectedValue(new Error('File not found')); + + const result = await adapter.execute( + { + action: 'similar', + query: 'nonexistent.ts', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('FILE_NOT_FOUND'); + }); + + it('should handle index not ready errors', async () => { + vi.mocked(mockIndexer.search).mockRejectedValue(new Error('Index not indexed')); + + const result = await adapter.execute( + { + action: 'pattern', + query: 'test', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INDEX_NOT_READY'); + }); + + it('should handle generic errors', async () => { + vi.mocked(mockIndexer.search).mockRejectedValue(new Error('Unknown error')); + + const result = await adapter.execute( + { + action: 'pattern', + query: 'test', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('EXPLORATION_ERROR'); + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/__tests__/github-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/github-adapter.test.ts new file mode 100644 index 0000000..f3ac96e --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/github-adapter.test.ts @@ -0,0 +1,441 @@ +/** + * GitHubAdapter Unit Tests + */ + +import type { + GitHubDocument, + GitHubIndexer, + GitHubSearchOptions, + GitHubSearchResult, +} from '@lytics/dev-agent-subagents'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GitHubAdapter } from '../built-in/github-adapter'; +import type { ToolExecutionContext } from '../types'; + +describe('GitHubAdapter', () => { + let adapter: GitHubAdapter; + let mockGitHubIndexer: GitHubIndexer; + let mockContext: ToolExecutionContext; + + const mockIssue: GitHubDocument = { + type: 'issue', + number: 1, + title: 'Test Issue', + body: 'This is a test issue', + state: 'open', + labels: ['bug', 'enhancement'], + author: 'testuser', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + url: 'https://github.com/test/repo/issues/1', + repository: 'test/repo', + comments: 5, + reactions: {}, + relatedIssues: [2, 3], + relatedPRs: [10], + linkedFiles: ['src/test.ts'], + mentions: ['developer1'], + }; + + beforeEach(() => { + // Mock GitHubIndexer + mockGitHubIndexer = { + search: vi.fn(), + } as unknown as GitHubIndexer; + + // Create adapter + adapter = new GitHubAdapter({ + repositoryPath: '/test/repo', + githubIndexer: mockGitHubIndexer, + defaultLimit: 10, + defaultFormat: 'compact', + }); + + // Mock execution context + mockContext = { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + } as unknown as ToolExecutionContext; + }); + + describe('Tool Definition', () => { + it('should return correct tool definition', () => { + const definition = adapter.getToolDefinition(); + + expect(definition.name).toBe('dev_gh'); + expect(definition.description).toContain('Search GitHub'); + expect(definition.inputSchema.required).toEqual(['action']); + expect(definition.inputSchema.properties.action.enum).toEqual([ + 'search', + 'context', + 'related', + ]); + }); + }); + + describe('Input Validation', () => { + it('should reject invalid action', async () => { + const result = await adapter.execute( + { + action: 'invalid', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_ACTION'); + }); + + it('should reject search without query', async () => { + const result = await adapter.execute( + { + action: 'search', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('MISSING_QUERY'); + }); + + it('should reject context without number', async () => { + const result = await adapter.execute( + { + action: 'context', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('MISSING_NUMBER'); + }); + + it('should reject related without number', async () => { + const result = await adapter.execute( + { + action: 'related', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('MISSING_NUMBER'); + }); + + it('should reject invalid limit', async () => { + const result = await adapter.execute( + { + action: 'search', + query: 'test', + limit: 0, + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_LIMIT'); + }); + + it('should reject invalid format', async () => { + const result = await adapter.execute( + { + action: 'search', + query: 'test', + format: 'invalid', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_FORMAT'); + }); + }); + + describe('Search Action', () => { + it('should search GitHub issues in compact format', async () => { + const mockResults: GitHubSearchResult[] = [ + { + document: mockIssue, + score: 0.9, + matchedFields: ['title', 'body'], + }, + ]; + + vi.mocked(mockGitHubIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'search', + query: 'test', + format: 'compact', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('GitHub Search Results'); + expect((result.data as { content: string })?.content).toContain('#1'); + expect((result.data as { content: string })?.content).toContain('Test Issue'); + }); + + it('should search with filters', async () => { + const mockResults: GitHubSearchResult[] = [ + { + document: mockIssue, + score: 0.9, + matchedFields: ['title'], + }, + ]; + + vi.mocked(mockGitHubIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'search', + query: 'test', + type: 'issue', + state: 'open', + labels: ['bug'], + author: 'testuser', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect(mockGitHubIndexer.search).toHaveBeenCalledWith('test', { + type: 'issue', + state: 'open', + labels: ['bug'], + author: 'testuser', + limit: 10, + }); + }); + + it('should handle no results', async () => { + vi.mocked(mockGitHubIndexer.search).mockResolvedValue([]); + + const result = await adapter.execute( + { + action: 'search', + query: 'nonexistent', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain( + 'No matching issues or PRs found' + ); + }); + + it('should include token footer in search results', async () => { + const mockResults: GitHubSearchResult[] = [ + { + document: mockIssue, + score: 0.9, + matchedFields: ['title'], + }, + ]; + + vi.mocked(mockGitHubIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'search', + query: 'test', + format: 'compact', + }, + mockContext + ); + + expect(result.success).toBe(true); + const content = (result.data as { content: string })?.content; + expect(content).toContain('šŸŖ™'); + expect(content).toMatch(/~\d+ tokens$/); + }); + }); + + describe('Context Action', () => { + it('should get issue context in compact format', async () => { + const mockResults: GitHubSearchResult[] = [ + { + document: mockIssue, + score: 1.0, + matchedFields: ['number'], + }, + ]; + + vi.mocked(mockGitHubIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'context', + number: 1, + format: 'compact', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('Issue #1'); + expect((result.data as { content: string })?.content).toContain('Test Issue'); + expect((result.data as { content: string })?.content).toContain('testuser'); + }); + + it('should get issue context in verbose format', async () => { + const mockResults: GitHubSearchResult[] = [ + { + document: mockIssue, + score: 1.0, + matchedFields: ['number'], + }, + ]; + + vi.mocked(mockGitHubIndexer.search).mockResolvedValue(mockResults); + + const result = await adapter.execute( + { + action: 'context', + number: 1, + format: 'verbose', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('**Related Issues:** #2, #3'); + expect((result.data as { content: string })?.content).toContain('**Related PRs:** #10'); + expect((result.data as { content: string })?.content).toContain( + '**Linked Files:** `src/test.ts`' + ); + expect((result.data as { content: string })?.content).toContain('**Mentions:** @developer1'); + }); + + it('should handle issue not found', async () => { + vi.mocked(mockGitHubIndexer.search).mockResolvedValue([]); + + const result = await adapter.execute( + { + action: 'context', + number: 999, + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('NOT_FOUND'); + }); + }); + + describe('Related Action', () => { + it('should find related issues in compact format', async () => { + const mockRelated: GitHubDocument = { + ...mockIssue, + number: 2, + title: 'Related Issue', + }; + + vi.mocked(mockGitHubIndexer.search) + .mockResolvedValueOnce([ + { + document: mockIssue, + score: 1.0, + matchedFields: ['number'], + }, + ]) + .mockResolvedValueOnce([ + { + document: mockIssue, + score: 1.0, + matchedFields: ['title'], + }, + { + document: mockRelated, + score: 0.85, + matchedFields: ['title'], + }, + ]); + + const result = await adapter.execute( + { + action: 'related', + number: 1, + format: 'compact', + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain('Related Issues/PRs'); + expect((result.data as { content: string })?.content).toContain('#2'); + expect((result.data as { content: string })?.content).toContain('Related Issue'); + }); + + it('should handle no related items', async () => { + vi.mocked(mockGitHubIndexer.search) + .mockResolvedValueOnce([ + { + document: mockIssue, + score: 1.0, + matchedFields: ['number'], + }, + ]) + .mockResolvedValueOnce([ + { + document: mockIssue, + score: 1.0, + matchedFields: ['title'], + }, + ]); + + const result = await adapter.execute( + { + action: 'related', + number: 1, + }, + mockContext + ); + + expect(result.success).toBe(true); + expect((result.data as { content: string })?.content).toContain( + 'No related issues or PRs found' + ); + }); + }); + + describe('Error Handling', () => { + it('should handle index not ready error', async () => { + vi.mocked(mockGitHubIndexer.search).mockRejectedValue(new Error('GitHub index not indexed')); + + const result = await adapter.execute( + { + action: 'search', + query: 'test', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INDEX_NOT_READY'); + }); + + it('should handle generic errors', async () => { + vi.mocked(mockGitHubIndexer.search).mockRejectedValue(new Error('Unknown error')); + + const result = await adapter.execute( + { + action: 'search', + query: 'test', + }, + mockContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('GITHUB_ERROR'); + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts index 7c0f896..0af0a8b 100644 --- a/packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts +++ b/packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts @@ -106,8 +106,8 @@ describe('StatusAdapter', () => { }); await adapter.initialize(mockContext); - expect(mockContext.logger.debug).toHaveBeenCalledWith( - 'GitHub indexer not available', + expect(mockContext.logger.warn).toHaveBeenCalledWith( + 'GitHub indexer initialization failed', expect.any(Object) ); }); diff --git a/packages/mcp-server/src/adapters/built-in/explore-adapter.ts b/packages/mcp-server/src/adapters/built-in/explore-adapter.ts new file mode 100644 index 0000000..5883e4b --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/explore-adapter.ts @@ -0,0 +1,502 @@ +/** + * Explore Adapter + * Exposes code exploration capabilities via MCP (dev_explore tool) + */ + +import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +export interface ExploreAdapterConfig { + repositoryPath: string; + repositoryIndexer: RepositoryIndexer; + defaultLimit?: number; + defaultThreshold?: number; + defaultFormat?: 'compact' | 'verbose'; +} + +/** + * ExploreAdapter - Code exploration via semantic search + * + * Provides pattern search, similar code detection, and relationship mapping + * through the dev_explore MCP tool. + */ +export class ExploreAdapter extends ToolAdapter { + metadata = { + name: 'explore', + version: '1.0.0', + description: 'Code exploration via semantic search', + }; + + private repositoryPath: string; + private repositoryIndexer: RepositoryIndexer; + private defaultLimit: number; + private defaultThreshold: number; + private defaultFormat: 'compact' | 'verbose'; + + constructor(config: ExploreAdapterConfig) { + super(); + this.repositoryPath = config.repositoryPath; + this.repositoryIndexer = config.repositoryIndexer; + this.defaultLimit = config.defaultLimit ?? 10; + this.defaultThreshold = config.defaultThreshold ?? 0.7; + this.defaultFormat = config.defaultFormat ?? 'compact'; + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('ExploreAdapter initialized', { + repositoryPath: this.repositoryPath, + defaultLimit: this.defaultLimit, + defaultThreshold: this.defaultThreshold, + defaultFormat: this.defaultFormat, + }); + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_explore', + description: + 'Explore code patterns and relationships using semantic search. Supports pattern search, similar code detection, and relationship mapping.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['pattern', 'similar', 'relationships'], + description: + 'Exploration action: "pattern" (search by concept), "similar" (find similar code to a file), "relationships" (map dependencies)', + }, + query: { + type: 'string', + description: + 'Search query (for pattern action) or file path (for similar/relationships actions)', + }, + limit: { + type: 'number', + description: `Maximum number of results (default: ${this.defaultLimit})`, + default: this.defaultLimit, + }, + threshold: { + type: 'number', + description: `Similarity threshold 0-1 (default: ${this.defaultThreshold})`, + default: this.defaultThreshold, + minimum: 0, + maximum: 1, + }, + fileTypes: { + type: 'array', + items: { type: 'string' }, + description: 'Filter results by file extensions (e.g., [".ts", ".js"])', + }, + format: { + type: 'string', + enum: ['compact', 'verbose'], + description: + 'Output format: "compact" for summaries (default), "verbose" for full details', + default: this.defaultFormat, + }, + }, + required: ['action', 'query'], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { + action, + query, + limit = this.defaultLimit, + threshold = this.defaultThreshold, + fileTypes, + format = this.defaultFormat, + } = args; + + // Validate action + if (action !== 'pattern' && action !== 'similar' && action !== 'relationships') { + return { + success: false, + error: { + code: 'INVALID_ACTION', + message: 'Action must be "pattern", "similar", or "relationships"', + }, + }; + } + + // Validate query + if (typeof query !== 'string' || query.trim().length === 0) { + return { + success: false, + error: { + code: 'INVALID_QUERY', + message: 'Query must be a non-empty string', + }, + }; + } + + // Validate limit + if (typeof limit !== 'number' || limit < 1 || limit > 100) { + return { + success: false, + error: { + code: 'INVALID_LIMIT', + message: 'Limit must be between 1 and 100', + }, + }; + } + + // Validate threshold + if (typeof threshold !== 'number' || threshold < 0 || threshold > 1) { + return { + success: false, + error: { + code: 'INVALID_THRESHOLD', + message: 'Threshold must be between 0 and 1', + }, + }; + } + + // Validate format + if (format !== 'compact' && format !== 'verbose') { + return { + success: false, + error: { + code: 'INVALID_FORMAT', + message: 'Format must be either "compact" or "verbose"', + }, + }; + } + + try { + context.logger.debug('Executing exploration', { action, query, limit, threshold }); + + let content: string; + + switch (action) { + case 'pattern': + content = await this.searchPattern( + query, + limit, + threshold, + fileTypes as string[] | undefined, + format + ); + break; + case 'similar': + content = await this.findSimilar(query, limit, threshold, format); + break; + case 'relationships': + content = await this.findRelationships(query, format); + break; + } + + return { + success: true, + data: { + action, + query, + format, + content, + }, + }; + } catch (error) { + context.logger.error('Exploration failed', { error }); + + if (error instanceof Error) { + if (error.message.includes('not found') || error.message.includes('does not exist')) { + return { + success: false, + error: { + code: 'FILE_NOT_FOUND', + message: `File not found: ${query}`, + suggestion: 'Check the file path and ensure it exists in the repository.', + }, + }; + } + + if (error.message.includes('not indexed')) { + return { + success: false, + error: { + code: 'INDEX_NOT_READY', + message: 'Code index is not ready', + suggestion: 'Run "dev scan" to index the repository.', + }, + }; + } + } + + return { + success: false, + error: { + code: 'EXPLORATION_ERROR', + message: error instanceof Error ? error.message : 'Unknown exploration error', + }, + }; + } + } + + /** + * Search for code patterns using semantic search + */ + private async searchPattern( + query: string, + limit: number, + threshold: number, + fileTypes: string[] | undefined, + format: string + ): Promise { + const results = await this.repositoryIndexer.search(query, { + limit, + scoreThreshold: threshold, + }); + + // Filter by file types if specified + const filteredResults = fileTypes + ? results.filter((r) => { + const path = r.metadata.path as string; + return fileTypes.some((ext) => path.endsWith(ext)); + }) + : results; + + if (filteredResults.length === 0) { + return '## Pattern Search Results\n\nNo matching patterns found. Try:\n- Using different keywords\n- Lowering the similarity threshold\n- Removing file type filters'; + } + + if (format === 'verbose') { + return this.formatPatternVerbose(query, filteredResults); + } + + return this.formatPatternCompact(query, filteredResults); + } + + /** + * Find code similar to a reference file + */ + private async findSimilar( + filePath: string, + limit: number, + threshold: number, + format: string + ): Promise { + const results = await this.repositoryIndexer.search(`file:${filePath}`, { + limit: limit + 1, + scoreThreshold: threshold, + }); + + // Exclude the reference file itself + const filteredResults = results.filter((r) => r.metadata.path !== filePath).slice(0, limit); + + if (filteredResults.length === 0) { + return `## Similar Code Search\n\n**Reference:** \`${filePath}\`\n\nNo similar code found. The file may be unique in the repository.`; + } + + if (format === 'verbose') { + return this.formatSimilarVerbose(filePath, filteredResults); + } + + return this.formatSimilarCompact(filePath, filteredResults); + } + + /** + * Find relationships for a code component + */ + private async findRelationships(filePath: string, format: string): Promise { + // Search for references to this file + const fileName = filePath.split('/').pop() || filePath; + const results = await this.repositoryIndexer.search(`import ${fileName}`, { + limit: 20, + scoreThreshold: 0.6, + }); + + if (results.length === 0) { + return `## Code Relationships\n\n**Component:** \`${filePath}\`\n\nNo relationships found. This component may not be imported by others.`; + } + + if (format === 'verbose') { + return this.formatRelationshipsVerbose(filePath, results); + } + + return this.formatRelationshipsCompact(filePath, results); + } + + /** + * Format pattern results in compact mode + */ + private formatPatternCompact( + query: string, + results: Array<{ id: string; score: number; metadata: Record }> + ): string { + const lines = [ + '## Pattern Search Results', + '', + `**Query:** "${query}"`, + `**Found:** ${results.length} matches`, + '', + ]; + + for (const result of results.slice(0, 5)) { + const score = (result.score * 100).toFixed(0); + const type = result.metadata.type || 'code'; + const name = result.metadata.name || '(unnamed)'; + lines.push(`- **${name}** (${type}) - \`${result.metadata.path}\` [${score}%]`); + } + + if (results.length > 5) { + lines.push('', `_...and ${results.length - 5} more results_`); + } + + return lines.join('\n'); + } + + /** + * Format pattern results in verbose mode + */ + private formatPatternVerbose( + query: string, + results: Array<{ id: string; score: number; metadata: Record }> + ): string { + const lines = [ + '## Pattern Search Results', + '', + `**Query:** "${query}"`, + `**Total Found:** ${results.length}`, + '', + ]; + + for (const result of results) { + const score = (result.score * 100).toFixed(1); + const type = result.metadata.type || 'code'; + const name = result.metadata.name || '(unnamed)'; + const path = result.metadata.path; + const startLine = result.metadata.startLine; + const endLine = result.metadata.endLine; + + lines.push(`### ${name}`); + lines.push(`- **Type:** ${type}`); + lines.push(`- **File:** \`${path}\``); + if (startLine && endLine) { + lines.push(`- **Lines:** ${startLine}-${endLine}`); + } + lines.push(`- **Similarity:** ${score}%`); + lines.push(''); + } + + return lines.join('\n'); + } + + /** + * Format similar code results in compact mode + */ + private formatSimilarCompact( + filePath: string, + results: Array<{ id: string; score: number; metadata: Record }> + ): string { + const lines = [ + '## Similar Code', + '', + `**Reference:** \`${filePath}\``, + `**Found:** ${results.length} similar files`, + '', + ]; + + for (const result of results.slice(0, 5)) { + const score = (result.score * 100).toFixed(0); + lines.push(`- \`${result.metadata.path}\` [${score}% similar]`); + } + + if (results.length > 5) { + lines.push('', `_...and ${results.length - 5} more files_`); + } + + return lines.join('\n'); + } + + /** + * Format similar code results in verbose mode + */ + private formatSimilarVerbose( + filePath: string, + results: Array<{ id: string; score: number; metadata: Record }> + ): string { + const lines = [ + '## Similar Code Analysis', + '', + `**Reference File:** \`${filePath}\``, + `**Total Matches:** ${results.length}`, + '', + ]; + + for (const result of results) { + const score = (result.score * 100).toFixed(1); + const type = result.metadata.type || 'file'; + const name = result.metadata.name || result.metadata.path; + + lines.push(`### ${name}`); + lines.push(`- **Path:** \`${result.metadata.path}\``); + lines.push(`- **Type:** ${type}`); + lines.push(`- **Similarity:** ${score}%`); + lines.push(''); + } + + return lines.join('\n'); + } + + /** + * Format relationships in compact mode + */ + private formatRelationshipsCompact( + filePath: string, + results: Array<{ id: string; score: number; metadata: Record }> + ): string { + const lines = [ + '## Code Relationships', + '', + `**Component:** \`${filePath}\``, + `**Used by:** ${results.length} files`, + '', + ]; + + for (const result of results.slice(0, 5)) { + lines.push(`- \`${result.metadata.path}\``); + } + + if (results.length > 5) { + lines.push('', `_...and ${results.length - 5} more files_`); + } + + return lines.join('\n'); + } + + /** + * Format relationships in verbose mode + */ + private formatRelationshipsVerbose( + filePath: string, + results: Array<{ id: string; score: number; metadata: Record }> + ): string { + const lines = [ + '## Code Relationships Analysis', + '', + `**Component:** \`${filePath}\``, + `**Total Dependencies:** ${results.length}`, + '', + ]; + + for (const result of results) { + const score = (result.score * 100).toFixed(1); + const type = result.metadata.type || 'unknown'; + const name = result.metadata.name || '(unnamed)'; + + lines.push(`### ${name}`); + lines.push(`- **Path:** \`${result.metadata.path}\``); + lines.push(`- **Type:** ${type}`); + lines.push(`- **Relevance:** ${score}%`); + if (result.metadata.startLine) { + lines.push(`- **Location:** Line ${result.metadata.startLine}`); + } + lines.push(''); + } + + return lines.join('\n'); + } +} diff --git a/packages/mcp-server/src/adapters/built-in/github-adapter.ts b/packages/mcp-server/src/adapters/built-in/github-adapter.ts new file mode 100644 index 0000000..5354852 --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/github-adapter.ts @@ -0,0 +1,610 @@ +/** + * GitHub Adapter + * Exposes GitHub context and search capabilities via MCP (dev_gh tool) + */ + +import type { + GitHubDocument, + GitHubIndexer, + GitHubSearchOptions, + GitHubSearchResult, +} from '@lytics/dev-agent-subagents'; +import { estimateTokensForText } from '../../formatters/utils'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +export interface GitHubAdapterConfig { + repositoryPath: string; + // Either pass an initialized indexer OR paths for lazy initialization + githubIndexer?: GitHubIndexer; + vectorStorePath?: string; + statePath?: string; + defaultLimit?: number; + defaultFormat?: 'compact' | 'verbose'; +} + +/** + * GitHubAdapter - GitHub issues and PRs search and context + * + * Provides semantic search across GitHub issues/PRs and contextual information + * through the dev_gh MCP tool. + */ +export class GitHubAdapter extends ToolAdapter { + metadata = { + name: 'github', + version: '1.0.0', + description: 'GitHub issues and PRs search and context', + }; + + private repositoryPath: string; + public githubIndexer?: GitHubIndexer; // Public for cleanup in shutdown + private vectorStorePath?: string; + private statePath?: string; + private defaultLimit: number; + private defaultFormat: 'compact' | 'verbose'; + + constructor(config: GitHubAdapterConfig) { + super(); + this.repositoryPath = config.repositoryPath; + this.githubIndexer = config.githubIndexer; + this.vectorStorePath = config.vectorStorePath; + this.statePath = config.statePath; + this.defaultLimit = config.defaultLimit ?? 10; + this.defaultFormat = config.defaultFormat ?? 'compact'; + + // Validate: either githubIndexer OR both paths must be provided + if (!this.githubIndexer && (!this.vectorStorePath || !this.statePath)) { + throw new Error( + 'GitHubAdapter requires either githubIndexer or both vectorStorePath and statePath' + ); + } + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('GitHubAdapter initialized', { + repositoryPath: this.repositoryPath, + defaultLimit: this.defaultLimit, + defaultFormat: this.defaultFormat, + lazyInit: !this.githubIndexer, + }); + } + + /** + * Lazy initialization of GitHubIndexer + * Only creates the indexer when first needed + */ + private async ensureGitHubIndexer(): Promise { + if (this.githubIndexer) { + return this.githubIndexer; + } + + // Validate paths are available + if (!this.vectorStorePath || !this.statePath) { + throw new Error('GitHubAdapter not configured for lazy initialization'); + } + + // Lazy initialization + const { GitHubIndexer: GitHubIndexerClass } = await import('@lytics/dev-agent-subagents'); + + // Try to load repository from state file to avoid gh CLI call + let repository: string | undefined; + try { + const fs = await import('node:fs/promises'); + const stateContent = await fs.readFile(this.statePath, 'utf-8'); + const state = JSON.parse(stateContent); + repository = state.repository; + } catch { + // State file doesn't exist or can't be read + // GitHubIndexer will try gh CLI as fallback + } + + this.githubIndexer = new GitHubIndexerClass( + { + vectorStorePath: this.vectorStorePath, + statePath: this.statePath, + autoUpdate: false, + }, + repository // Pass repository to avoid gh CLI call + ); + + await this.githubIndexer.initialize(); + return this.githubIndexer; + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_gh', + description: + 'Search GitHub issues and pull requests using semantic search. Supports filtering by type, state, labels, and more.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['search', 'context', 'related'], + description: + 'GitHub action: "search" (semantic search), "context" (get full context for issue/PR), "related" (find related issues/PRs)', + }, + query: { + type: 'string', + description: 'Search query (for search action)', + }, + number: { + type: 'number', + description: 'Issue or PR number (for context/related actions)', + }, + type: { + type: 'string', + enum: ['issue', 'pull_request'], + description: 'Filter by document type (default: both)', + }, + state: { + type: 'string', + enum: ['open', 'closed', 'merged'], + description: 'Filter by state (default: all states)', + }, + labels: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by labels (e.g., ["bug", "enhancement"])', + }, + author: { + type: 'string', + description: 'Filter by author username', + }, + limit: { + type: 'number', + description: `Maximum number of results (default: ${this.defaultLimit})`, + default: this.defaultLimit, + }, + format: { + type: 'string', + enum: ['compact', 'verbose'], + description: + 'Output format: "compact" for summaries (default), "verbose" for full details', + default: this.defaultFormat, + }, + }, + required: ['action'], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { + action, + query, + number, + type, + state, + labels, + author, + limit = this.defaultLimit, + format = this.defaultFormat, + } = args; + + // Validate action + if (action !== 'search' && action !== 'context' && action !== 'related') { + return { + success: false, + error: { + code: 'INVALID_ACTION', + message: 'Action must be "search", "context", or "related"', + }, + }; + } + + // Validate action-specific requirements + if (action === 'search' && (typeof query !== 'string' || query.trim().length === 0)) { + return { + success: false, + error: { + code: 'MISSING_QUERY', + message: 'Search action requires a query parameter', + }, + }; + } + + if ((action === 'context' || action === 'related') && typeof number !== 'number') { + return { + success: false, + error: { + code: 'MISSING_NUMBER', + message: `${action} action requires a number parameter`, + }, + }; + } + + // Validate limit + if (typeof limit !== 'number' || limit < 1 || limit > 100) { + return { + success: false, + error: { + code: 'INVALID_LIMIT', + message: 'Limit must be between 1 and 100', + }, + }; + } + + // Validate format + if (format !== 'compact' && format !== 'verbose') { + return { + success: false, + error: { + code: 'INVALID_FORMAT', + message: 'Format must be either "compact" or "verbose"', + }, + }; + } + + try { + context.logger.debug('Executing GitHub action', { action, query, number }); + + let content: string; + + switch (action) { + case 'search': + content = await this.searchGitHub( + query as string, + { + type: type as 'issue' | 'pull_request' | undefined, + state: state as 'open' | 'closed' | 'merged' | undefined, + labels: labels as string[] | undefined, + author: author as string | undefined, + limit, + }, + format + ); + break; + case 'context': + content = await this.getContext(number as number, format); + break; + case 'related': + content = await this.getRelated(number as number, limit, format); + break; + } + + return { + success: true, + data: { + action, + query: query || number, + format, + content, + }, + }; + } catch (error) { + context.logger.error('GitHub action failed', { error }); + + if (error instanceof Error) { + if (error.message.includes('not indexed')) { + return { + success: false, + error: { + code: 'INDEX_NOT_READY', + message: 'GitHub index is not ready', + suggestion: 'Run "dev gh index" to index GitHub issues and PRs.', + }, + }; + } + + if (error.message.includes('not found')) { + return { + success: false, + error: { + code: 'NOT_FOUND', + message: `GitHub issue/PR #${number} not found`, + suggestion: 'Check the issue/PR number or re-index GitHub data.', + }, + }; + } + } + + return { + success: false, + error: { + code: 'GITHUB_ERROR', + message: error instanceof Error ? error.message : 'Unknown GitHub error', + }, + }; + } + } + + /** + * Search GitHub issues and PRs + */ + private async searchGitHub( + query: string, + options: GitHubSearchOptions, + format: string + ): Promise { + const indexer = await this.ensureGitHubIndexer(); + const results = await indexer.search(query, options); + + if (results.length === 0) { + const noResultsMsg = + '## GitHub Search Results\n\nNo matching issues or PRs found. Try:\n- Using different keywords\n- Removing filters (type, state, labels)\n- Re-indexing GitHub data with "dev gh index"'; + const tokens = estimateTokensForText(noResultsMsg); + return `${noResultsMsg}\n\nšŸŖ™ ~${tokens} tokens`; + } + + if (format === 'verbose') { + return this.formatSearchVerbose(query, results, options); + } + + return this.formatSearchCompact(query, results, options); + } + + /** + * Get full context for an issue/PR + */ + private async getContext(number: number, format: string): Promise { + // Search for the specific issue/PR + const indexer = await this.ensureGitHubIndexer(); + const results = await indexer.search(`#${number}`, { limit: 1 }); + + if (results.length === 0) { + throw new Error(`Issue/PR #${number} not found`); + } + + const doc = results[0].document; + + if (format === 'verbose') { + return this.formatContextVerbose(doc); + } + + return this.formatContextCompact(doc); + } + + /** + * Find related issues and PRs + */ + private async getRelated(number: number, limit: number, format: string): Promise { + // First get the main issue/PR + const indexer = await this.ensureGitHubIndexer(); + const mainResults = await indexer.search(`#${number}`, { limit: 1 }); + + if (mainResults.length === 0) { + throw new Error(`Issue/PR #${number} not found`); + } + + const mainDoc = mainResults[0].document; + + // Search for related items using the title + const relatedResults = await indexer.search(mainDoc.title, { limit: limit + 1 }); + + // Filter out the main item itself + const related = relatedResults.filter((r) => r.document.number !== number).slice(0, limit); + + if (related.length === 0) { + return `## Related Issues/PRs\n\n**#${number}: ${mainDoc.title}**\n\nNo related issues or PRs found.`; + } + + if (format === 'verbose') { + return this.formatRelatedVerbose(mainDoc, related); + } + + return this.formatRelatedCompact(mainDoc, related); + } + + /** + * Format search results in compact mode + */ + private formatSearchCompact( + query: string, + results: GitHubSearchResult[], + options: GitHubSearchOptions + ): string { + const filters: string[] = []; + if (options.type) filters.push(`type:${options.type}`); + if (options.state) filters.push(`state:${options.state}`); + if (options.labels?.length) filters.push(`labels:[${options.labels.join(',')}]`); + if (options.author) filters.push(`author:${options.author}`); + + const lines = [ + '## GitHub Search Results', + '', + `**Query:** "${query}"`, + filters.length > 0 ? `**Filters:** ${filters.join(', ')}` : null, + `**Found:** ${results.length} results`, + '', + ].filter(Boolean) as string[]; + + for (const result of results.slice(0, 5)) { + const doc = result.document; + const score = (result.score * 100).toFixed(0); + const icon = doc.type === 'issue' ? 'šŸ”µ' : '🟣'; + const stateIcon = doc.state === 'open' ? 'ā—‹' : doc.state === 'merged' ? 'ā—' : 'Ɨ'; + lines.push(`- ${icon} ${stateIcon} **#${doc.number}**: ${doc.title} [${score}%]`); + } + + if (results.length > 5) { + lines.push('', `_...and ${results.length - 5} more results_`); + } + + const content = lines.join('\n'); + const tokens = estimateTokensForText(content); + return `${content}\n\nšŸŖ™ ~${tokens} tokens`; + } + + /** + * Format search results in verbose mode + */ + private formatSearchVerbose( + query: string, + results: GitHubSearchResult[], + options: GitHubSearchOptions + ): string { + const filters: string[] = []; + if (options.type) filters.push(`type:${options.type}`); + if (options.state) filters.push(`state:${options.state}`); + if (options.labels?.length) filters.push(`labels:[${options.labels.join(',')}]`); + if (options.author) filters.push(`author:${options.author}`); + + const lines = [ + '## GitHub Search Results', + '', + `**Query:** "${query}"`, + filters.length > 0 ? `**Filters:** ${filters.join(', ')}` : null, + `**Total Found:** ${results.length}`, + '', + ].filter(Boolean) as string[]; + + for (const result of results) { + const doc = result.document; + const score = (result.score * 100).toFixed(1); + const typeLabel = doc.type === 'issue' ? 'Issue' : 'Pull Request'; + + lines.push(`### #${doc.number}: ${doc.title}`); + lines.push(`- **Type:** ${typeLabel}`); + lines.push(`- **State:** ${doc.state}`); + lines.push(`- **Author:** ${doc.author}`); + if (doc.labels.length > 0) { + lines.push(`- **Labels:** ${doc.labels.join(', ')}`); + } + lines.push(`- **Created:** ${new Date(doc.createdAt).toLocaleDateString()}`); + lines.push(`- **Relevance:** ${score}%`); + lines.push(`- **URL:** ${doc.url}`); + lines.push(''); + } + + const content = lines.join('\n'); + const tokens = estimateTokensForText(content); + return `${content}\n\nšŸŖ™ ~${tokens} tokens`; + } + + /** + * Format context in compact mode + */ + private formatContextCompact(doc: GitHubDocument): string { + const typeLabel = doc.type === 'issue' ? 'Issue' : 'Pull Request'; + const stateIcon = + doc.state === 'open' ? 'ā—‹ Open' : doc.state === 'merged' ? 'ā— Merged' : 'Ɨ Closed'; + + const lines = [ + `## ${typeLabel} #${doc.number}`, + '', + `**${doc.title}**`, + '', + `**Status:** ${stateIcon}`, + `**Author:** ${doc.author}`, + doc.labels.length > 0 ? `**Labels:** ${doc.labels.join(', ')}` : null, + `**Created:** ${new Date(doc.createdAt).toLocaleDateString()}`, + '', + '**Description:**', + doc.body.slice(0, 300) + (doc.body.length > 300 ? '...' : ''), + '', + `**URL:** ${doc.url}`, + ].filter(Boolean) as string[]; + + const content = lines.join('\n'); + const tokens = estimateTokensForText(content); + return `${content}\n\nšŸŖ™ ~${tokens} tokens`; + } + + /** + * Format context in verbose mode + */ + private formatContextVerbose(doc: GitHubDocument): string { + const typeLabel = doc.type === 'issue' ? 'Issue' : 'Pull Request'; + const stateIcon = + doc.state === 'open' ? 'ā—‹ Open' : doc.state === 'merged' ? 'ā— Merged' : 'Ɨ Closed'; + + const lines = [ + `## ${typeLabel} #${doc.number}: ${doc.title}`, + '', + `**Status:** ${stateIcon}`, + `**Author:** ${doc.author}`, + doc.labels.length > 0 ? `**Labels:** ${doc.labels.join(', ')}` : null, + `**Created:** ${new Date(doc.createdAt).toLocaleString()}`, + `**Updated:** ${new Date(doc.updatedAt).toLocaleString()}`, + doc.closedAt ? `**Closed:** ${new Date(doc.closedAt).toLocaleString()}` : null, + doc.mergedAt ? `**Merged:** ${new Date(doc.mergedAt).toLocaleString()}` : null, + doc.headBranch ? `**Branch:** ${doc.headBranch} → ${doc.baseBranch}` : null, + `**Comments:** ${doc.comments}`, + '', + '**Description:**', + '', + doc.body, + '', + doc.relatedIssues.length > 0 + ? `**Related Issues:** ${doc.relatedIssues.map((n: number) => `#${n}`).join(', ')}` + : null, + doc.relatedPRs.length > 0 + ? `**Related PRs:** ${doc.relatedPRs.map((n: number) => `#${n}`).join(', ')}` + : null, + doc.linkedFiles.length > 0 + ? `**Linked Files:** ${doc.linkedFiles.map((f: string) => `\`${f}\``).join(', ')}` + : null, + doc.mentions.length > 0 + ? `**Mentions:** ${doc.mentions.map((m: string) => `@${m}`).join(', ')}` + : null, + '', + `**URL:** ${doc.url}`, + ].filter(Boolean) as string[]; + + const content = lines.join('\n'); + const tokens = estimateTokensForText(content); + return `${content}\n\nšŸŖ™ ~${tokens} tokens`; + } + + /** + * Format related items in compact mode + */ + private formatRelatedCompact(mainDoc: GitHubDocument, related: GitHubSearchResult[]): string { + const lines = [ + '## Related Issues/PRs', + '', + `**#${mainDoc.number}: ${mainDoc.title}**`, + '', + `**Found:** ${related.length} related items`, + '', + ]; + + for (const result of related.slice(0, 5)) { + const doc = result.document; + const score = (result.score * 100).toFixed(0); + const icon = doc.type === 'issue' ? 'šŸ”µ' : '🟣'; + lines.push(`- ${icon} **#${doc.number}**: ${doc.title} [${score}% similar]`); + } + + if (related.length > 5) { + lines.push('', `_...and ${related.length - 5} more items_`); + } + + return lines.join('\n'); + } + + /** + * Format related items in verbose mode + */ + private formatRelatedVerbose(mainDoc: GitHubDocument, related: GitHubSearchResult[]): string { + const lines = [ + '## Related Issues and Pull Requests', + '', + `**Reference: #${mainDoc.number} - ${mainDoc.title}**`, + '', + `**Total Related:** ${related.length}`, + '', + ]; + + for (const result of related) { + const doc = result.document; + const score = (result.score * 100).toFixed(1); + const typeLabel = doc.type === 'issue' ? 'Issue' : 'Pull Request'; + + lines.push(`### #${doc.number}: ${doc.title}`); + lines.push(`- **Type:** ${typeLabel}`); + lines.push(`- **State:** ${doc.state}`); + lines.push(`- **Author:** ${doc.author}`); + if (doc.labels.length > 0) { + lines.push(`- **Labels:** ${doc.labels.join(', ')}`); + } + lines.push(`- **Similarity:** ${score}%`); + lines.push(`- **URL:** ${doc.url}`); + lines.push(''); + } + + return lines.join('\n'); + } +} diff --git a/packages/mcp-server/src/adapters/built-in/index.ts b/packages/mcp-server/src/adapters/built-in/index.ts index 82506b2..23713fe 100644 --- a/packages/mcp-server/src/adapters/built-in/index.ts +++ b/packages/mcp-server/src/adapters/built-in/index.ts @@ -3,6 +3,8 @@ * Production-ready adapters included with the MCP server */ +export { ExploreAdapter, type ExploreAdapterConfig } from './explore-adapter'; +export { GitHubAdapter, type GitHubAdapterConfig } from './github-adapter'; export { PlanAdapter, type PlanAdapterConfig } from './plan-adapter'; export { SearchAdapter, type SearchAdapterConfig } from './search-adapter'; export { StatusAdapter, type StatusAdapterConfig } from './status-adapter'; diff --git a/packages/mcp-server/src/adapters/built-in/status-adapter.ts b/packages/mcp-server/src/adapters/built-in/status-adapter.ts index 131690c..b60eb06 100644 --- a/packages/mcp-server/src/adapters/built-in/status-adapter.ts +++ b/packages/mcp-server/src/adapters/built-in/status-adapter.ts @@ -74,15 +74,29 @@ export class StatusAdapter extends ToolAdapter { // Initialize GitHub indexer lazily try { - this.githubIndexer = new GitHubIndexer({ - vectorStorePath: `${this.vectorStorePath}-github`, - statePath: '.dev-agent/github-state.json', - autoUpdate: true, - staleThreshold: 15 * 60 * 1000, - }); + // Try to load repository from state file + let repository: string | undefined; + const statePath = path.join(this.repositoryPath, '.dev-agent/github-state.json'); + try { + const stateContent = await fs.promises.readFile(statePath, 'utf-8'); + const state = JSON.parse(stateContent); + repository = state.repository; + } catch { + // State file doesn't exist, will try gh CLI + } + + this.githubIndexer = new GitHubIndexer( + { + vectorStorePath: `${this.vectorStorePath}-github`, + statePath, // Use absolute path + autoUpdate: true, + staleThreshold: 15 * 60 * 1000, + }, + repository + ); await this.githubIndexer.initialize(); } catch (error) { - context.logger.debug('GitHub indexer not available', { error }); + context.logger.warn('GitHub indexer initialization failed', { error }); // Not fatal, GitHub section will show "not indexed" } } diff --git a/packages/mcp-server/src/formatters/__tests__/formatters.test.ts b/packages/mcp-server/src/formatters/__tests__/formatters.test.ts index 02aa326..16122f9 100644 --- a/packages/mcp-server/src/formatters/__tests__/formatters.test.ts +++ b/packages/mcp-server/src/formatters/__tests__/formatters.test.ts @@ -70,14 +70,17 @@ describe('Formatters', () => { expect(result.content).toContain('2. [84%]'); expect(result.content).toContain('3. [72%]'); expect(result.tokenEstimate).toBeGreaterThan(0); + // Should include token footer + expect(result.content).toMatch(/šŸŖ™ ~\d+ tokens$/); }); it('should respect maxResults option', () => { const formatter = new CompactFormatter({ maxResults: 2 }); const result = formatter.formatResults(mockResults); - const lines = result.content.split('\n'); - expect(lines).toHaveLength(2); // Only 2 results + // Count actual result lines (numbered lines) + const resultLines = result.content.split('\n').filter((l) => /^\d+\./.test(l)); + expect(resultLines).toHaveLength(2); // Only 2 results }); it('should exclude signatures by default', () => { @@ -139,6 +142,9 @@ describe('Formatters', () => { // Should have double newlines between results expect(result.content).toContain('\n\n'); + + // Should include token footer + expect(result.content).toMatch(/šŸŖ™ ~\d+ tokens$/); }); it('should include signatures by default', () => { @@ -215,4 +221,34 @@ describe('Formatters', () => { expect(threeResults.tokenEstimate).toBeGreaterThan(oneResult.tokenEstimate * 2); }); }); + + describe('Token Footer', () => { + it('compact formatter should include coin emoji footer', () => { + const formatter = new CompactFormatter(); + const result = formatter.formatResults(mockResults); + + expect(result.content).toContain('šŸŖ™'); + expect(result.content).toMatch(/~\d+ tokens$/); + }); + + it('verbose formatter should include coin emoji footer', () => { + const formatter = new VerboseFormatter(); + const result = formatter.formatResults(mockResults); + + expect(result.content).toContain('šŸŖ™'); + expect(result.content).toMatch(/~\d+ tokens$/); + }); + + it('token footer should match tokenEstimate property', () => { + const formatter = new CompactFormatter(); + const result = formatter.formatResults(mockResults); + + // Extract token count from footer + const footerMatch = result.content.match(/šŸŖ™ ~(\d+) tokens$/); + expect(footerMatch).toBeTruthy(); + + const footerTokens = Number.parseInt(footerMatch![1], 10); + expect(footerTokens).toBe(result.tokenEstimate); + }); + }); }); diff --git a/packages/mcp-server/src/formatters/__tests__/utils.test.ts b/packages/mcp-server/src/formatters/__tests__/utils.test.ts index c1399ec..42b9fef 100644 --- a/packages/mcp-server/src/formatters/__tests__/utils.test.ts +++ b/packages/mcp-server/src/formatters/__tests__/utils.test.ts @@ -155,5 +155,47 @@ describe('Formatter Utils', () => { expect(ratio).toBeLessThan(2); } }); + + it('should use calibrated 4.5 chars per token formula', () => { + // Test the calibrated formula matches actual usage + // Known: 803 chars normalized = 178 tokens actual + const testText = '## GitHub Search Results\n'.repeat(20); // ~520 chars + const normalized = testText.trim().replace(/\s+/g, ' '); + const estimate = estimateTokensForText(testText); + const expectedFromFormula = Math.ceil(normalized.length / 4.5); + + // Should use the calibrated 4.5 ratio + expect(estimate).toBeGreaterThanOrEqual(expectedFromFormula - 5); + expect(estimate).toBeLessThanOrEqual(expectedFromFormula + 5); + }); + + it('should estimate within 5% for technical content', () => { + // Real test case from actual usage (full text) + const technicalText = `## GitHub Search Results +**Query:** "token estimation and cost tracking" +**Total Found:** 3 + +1. [Score: 29.6%] function: estimateTokensForText + Location: packages/mcp-server/src/formatters/utils.ts:15 + Signature: export function estimateTokensForText(text: string): number + Metadata: language: typescript, exported: true, lines: 19 + +2. [Score: 21.0%] function: estimateTokensForJSON + Location: packages/mcp-server/src/formatters/utils.ts:63 + Signature: export function estimateTokensForJSON(obj: unknown): number + Metadata: language: typescript, exported: true, lines: 4 + +3. [Score: 19.7%] method: VerboseFormatter.estimateTokens + Location: packages/mcp-server/src/formatters/verbose-formatter.ts:114 + Signature: estimateTokens(result: SearchResult): number + Metadata: language: typescript, exported: true, lines: 3`; + + const estimate = estimateTokensForText(technicalText); + const actualTokens = 178; // Verified from Cursor + + // Should be within 5% of actual (calibrated at 0.6%) + const errorPercent = Math.abs((estimate - actualTokens) / actualTokens) * 100; + expect(errorPercent).toBeLessThan(5); + }); }); }); diff --git a/packages/mcp-server/src/formatters/compact-formatter.ts b/packages/mcp-server/src/formatters/compact-formatter.ts index 230e5f8..0f00b79 100644 --- a/packages/mcp-server/src/formatters/compact-formatter.ts +++ b/packages/mcp-server/src/formatters/compact-formatter.ts @@ -72,8 +72,11 @@ export class CompactFormatter implements ResultFormatter { }); // Calculate total tokens - const content = formatted.join('\n'); - const tokenEstimate = estimateTokensForText(content); + const contentLines = formatted.join('\n'); + const tokenEstimate = estimateTokensForText(contentLines); + + // Add token footer + const content = `${contentLines}\n\nšŸŖ™ ~${tokenEstimate} tokens`; return { content, diff --git a/packages/mcp-server/src/formatters/utils.ts b/packages/mcp-server/src/formatters/utils.ts index c85baec..dc022c3 100644 --- a/packages/mcp-server/src/formatters/utils.ts +++ b/packages/mcp-server/src/formatters/utils.ts @@ -4,10 +4,10 @@ */ /** - * Estimate tokens for text using a simple heuristic + * Estimate tokens for text using a calibrated heuristic * - * Rule of thumb: ~4 characters per token for English text - * This is a conservative estimate (GPT-4 tokenization) + * Rule of thumb: ~4.5 characters per token for technical/code text + * Calibrated against actual GPT-4 tokenization (12.9% -> ~3-5% error) * * @param text - The text to estimate tokens for * @returns Estimated token count @@ -21,14 +21,15 @@ export function estimateTokensForText(text: string): number { return 0; } - // Estimate: 4 characters per token (conservative for code/technical text) - const charBasedEstimate = Math.ceil(normalized.length / 4); + // Calibrated estimates for technical/code content + // 4.5 chars/token is more accurate than the standard 4 chars/token + const charBasedEstimate = Math.ceil(normalized.length / 4.5); - // Word-based estimate (fallback) + // Word-based estimate (fallback for text-heavy content) const words = normalized.split(/\s+/).length; - const wordBasedEstimate = Math.ceil(words * 1.3); // ~1.3 tokens per word + const wordBasedEstimate = Math.ceil(words * 1.25); // ~1.25 tokens per word - // Use the higher estimate (more conservative) + // Use the higher estimate (slightly conservative) return Math.max(charBasedEstimate, wordBasedEstimate); } diff --git a/packages/mcp-server/src/formatters/verbose-formatter.ts b/packages/mcp-server/src/formatters/verbose-formatter.ts index 4f9218a..0c29b3c 100644 --- a/packages/mcp-server/src/formatters/verbose-formatter.ts +++ b/packages/mcp-server/src/formatters/verbose-formatter.ts @@ -102,8 +102,11 @@ export class VerboseFormatter implements ResultFormatter { }); // Calculate total tokens - const content = formatted.join('\n\n'); // Double newline for separation - const tokenEstimate = estimateTokensForText(content); + const contentLines = formatted.join('\n\n'); // Double newline for separation + const tokenEstimate = estimateTokensForText(contentLines); + + // Add token footer + const content = `${contentLines}\n\nšŸŖ™ ~${tokenEstimate} tokens`; return { content, diff --git a/packages/mcp-server/src/server/mcp-server.ts b/packages/mcp-server/src/server/mcp-server.ts index a274107..f1526ce 100644 --- a/packages/mcp-server/src/server/mcp-server.ts +++ b/packages/mcp-server/src/server/mcp-server.ts @@ -7,6 +7,7 @@ import { AdapterRegistry, type RegistryConfig } from '../adapters/adapter-regist import type { ToolAdapter } from '../adapters/tool-adapter'; import type { AdapterContext, Config, ToolExecutionContext } from '../adapters/types'; import { ConsoleLogger } from '../utils/logger'; +import { PromptRegistry } from './prompts'; import { JSONRPCHandler } from './protocol/jsonrpc'; import type { ErrorCode, @@ -29,6 +30,7 @@ export interface MCPServerConfig { export class MCPServer { private registry: AdapterRegistry; + private promptRegistry: PromptRegistry; private transport: Transport; private logger = new ConsoleLogger('[MCP Server]', 'debug'); // Enable debug logging private config: Config; @@ -39,6 +41,7 @@ export class MCPServer { this.config = config.config; this.serverInfo = config.serverInfo; this.registry = new AdapterRegistry(config.registry || {}); + this.promptRegistry = new PromptRegistry(); // Create transport if (config.transport === 'stdio' || !config.transport) { @@ -169,10 +172,16 @@ export class MCPServer { request.params as { name: string; arguments: Record } ); - case 'resources/list': - case 'resources/read': case 'prompts/list': + return this.handlePromptsList(); + case 'prompts/get': + return this.handlePromptsGet( + request.params as { name: string; arguments?: Record } + ); + + case 'resources/list': + case 'resources/read': throw JSONRPCHandler.createError(-32601, `Method not implemented: ${method}`); default: @@ -196,7 +205,7 @@ export class MCPServer { const capabilities: ServerCapabilities = { tools: { supported: true }, resources: { supported: false }, // Not yet implemented - prompts: { supported: false }, // Not yet implemented + prompts: { supported: true }, }; return { @@ -270,6 +279,45 @@ export class MCPServer { }; } + /** + * Handle prompts/list request + */ + private handlePromptsList(): { prompts: unknown[] } { + this.logger.debug('Listing prompts'); + const prompts = this.promptRegistry.listPrompts(); + this.logger.info('Prompts listed', { count: prompts.length }); + return { prompts }; + } + + /** + * Handle prompts/get request + */ + private handlePromptsGet(params: { name: string; arguments?: Record }): unknown { + this.logger.debug('Getting prompt', { name: params.name, arguments: params.arguments }); + + try { + const prompt = this.promptRegistry.getPrompt(params.name, params.arguments || {}); + + if (!prompt) { + throw JSONRPCHandler.createError( + -32003 as ErrorCode, // PromptNotFound + `Prompt not found: ${params.name}` + ); + } + + this.logger.info('Prompt retrieved', { name: params.name }); + return prompt; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Missing required argument')) { + throw JSONRPCHandler.createError( + -32602 as ErrorCode, // InvalidParams + error.message + ); + } + throw error; + } + } + /** * Handle transport errors */ diff --git a/packages/mcp-server/src/server/prompts.ts b/packages/mcp-server/src/server/prompts.ts new file mode 100644 index 0000000..7a05dbd --- /dev/null +++ b/packages/mcp-server/src/server/prompts.ts @@ -0,0 +1,411 @@ +/** + * MCP Prompts Registry + * Defines reusable prompt templates that guide users through common workflows + */ + +import type { PromptArgument, PromptDefinition } from './protocol/types'; + +/** + * Prompt message with role and content + */ +export interface PromptMessage { + role: 'user' | 'assistant'; + content: { + type: 'text'; + text: string; + }; +} + +/** + * Complete prompt with messages + */ +export interface Prompt { + description?: string; + messages: PromptMessage[]; +} + +/** + * Registry of all available prompts + */ +export class PromptRegistry { + private prompts: Map< + string, + { definition: PromptDefinition; generator: (args: Record) => Prompt } + > = new Map(); + + constructor() { + this.registerDefaultPrompts(); + } + + /** + * Register default prompts that ship with dev-agent + */ + private registerDefaultPrompts(): void { + // Analyze GitHub Issue + this.register( + { + name: 'analyze-issue', + description: 'Analyze a GitHub issue and create an implementation plan', + arguments: [ + { + name: 'issue_number', + description: 'GitHub issue number to analyze', + required: true, + }, + { + name: 'detail_level', + description: 'Level of detail for the plan (simple or detailed)', + required: false, + }, + ], + }, + (args) => ({ + description: `Analyze GitHub issue #${args.issue_number} and create implementation plan`, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please analyze GitHub issue #${args.issue_number} and create a detailed implementation plan. + +Steps: +1. Use dev_gh with action "context" to get full issue details and related items +2. Use dev_search to find relevant code that needs to be modified +3. Use dev_plan to generate a structured implementation plan${args.detail_level ? ` with detailLevel "${args.detail_level}"` : ''} +4. Summarize the approach, key files, and estimated complexity`, + }, + }, + ], + }) + ); + + // Search for Code Pattern + this.register( + { + name: 'find-pattern', + description: 'Search the codebase for specific patterns or functionality', + arguments: [ + { + name: 'description', + description: + 'What to search for (e.g., "authentication middleware", "database queries")', + required: true, + }, + { + name: 'file_types', + description: 'Comma-separated file extensions to filter (e.g., ".ts,.js")', + required: false, + }, + ], + }, + (args) => ({ + description: `Find code matching: ${args.description}`, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Find all code related to "${args.description}" in the repository. + +Use dev_explore with action "pattern" to search for: ${args.description}${args.file_types ? `\nFilter by file types: ${args.file_types}` : ''} + +Then provide: +1. Summary of what you found +2. Key files and their purposes +3. Common patterns or approaches used +4. Suggestions for modifications if relevant`, + }, + }, + ], + }) + ); + + // Repository Status Overview + this.register( + { + name: 'repo-overview', + description: 'Get comprehensive overview of repository health and statistics', + arguments: [], + }, + () => ({ + description: 'Repository health and statistics overview', + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Provide a comprehensive overview of the repository status: + +1. Use dev_status with section "summary" and format "verbose" for detailed stats +2. Use dev_gh with action "search" to find recent open issues (limit 5) +3. Summarize: + - Repository health (indexing status, storage size) + - Code metrics (files, components, vectors) + - GitHub activity (open issues, recent PRs) + - Any recommendations for maintenance`, + }, + }, + ], + }) + ); + + // Find Similar Code + this.register( + { + name: 'find-similar', + description: 'Find code similar to a specific file or component', + arguments: [ + { + name: 'file_path', + description: 'Path to the file to find similar code for', + required: true, + }, + { + name: 'threshold', + description: 'Similarity threshold (0-1, default: 0.7)', + required: false, + }, + ], + }, + (args) => ({ + description: `Find code similar to ${args.file_path}`, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Find code that is similar to "${args.file_path}": + +Use dev_explore with: +- action: "similar" +- query: "${args.file_path}"${args.threshold ? `\n- threshold: ${args.threshold}` : ''} + +Then explain: +1. What patterns the file uses +2. Other files with similar patterns +3. How they relate (dependencies, parallel implementations, etc.) +4. Opportunities for refactoring or code reuse`, + }, + }, + ], + }) + ); + + // Search GitHub Issues/PRs + this.register( + { + name: 'search-github', + description: 'Search GitHub issues and pull requests by topic', + arguments: [ + { + name: 'query', + description: + 'What to search for (e.g., "authentication bug", "performance improvement")', + required: true, + }, + { + name: 'type', + description: 'Filter by type: "issue" or "pull_request"', + required: false, + }, + { + name: 'state', + description: 'Filter by state: "open", "closed", or "merged"', + required: false, + }, + ], + }, + (args) => ({ + description: `Search GitHub for: ${args.query}`, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Search GitHub for "${args.query}": + +Use dev_gh with: +- action: "search" +- query: "${args.query}"${args.type ? `\n- type: "${args.type}"` : ''}${args.state ? `\n- state: "${args.state}"` : ''} +- limit: 10 + +Provide: +1. Summary of relevant items found +2. Key themes or patterns +3. Status overview (how many open vs closed) +4. Suggestions for next steps`, + }, + }, + ], + }) + ); + + // Code Relationships + this.register( + { + name: 'explore-relationships', + description: 'Explore dependencies and relationships in the codebase', + arguments: [ + { + name: 'file_path', + description: 'Path to the file to analyze relationships for', + required: true, + }, + ], + }, + (args) => ({ + description: `Explore relationships for ${args.file_path}`, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Analyze the relationships and dependencies for "${args.file_path}": + +Use dev_explore with: +- action: "relationships" +- query: "${args.file_path}" + +Then explain: +1. What this file depends on (imports) +2. What depends on this file (used by) +3. Key integration points +4. Impact analysis (what breaks if this changes) +5. Refactoring considerations`, + }, + }, + ], + }) + ); + + // Implementation Planning + this.register( + { + name: 'create-plan', + description: 'Create detailed implementation plan for a GitHub issue', + arguments: [ + { + name: 'issue_number', + description: 'GitHub issue number to plan for', + required: true, + }, + { + name: 'detail_level', + description: 'Plan detail level: "simple" or "detailed" (default)', + required: false, + }, + { + name: 'use_explorer', + description: 'Use semantic search to find relevant code (true/false, default: true)', + required: false, + }, + ], + }, + (args) => ({ + description: `Create implementation plan for issue #${args.issue_number}`, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Create an implementation plan for GitHub issue #${args.issue_number}: + +Use dev_plan with: +- issue: ${args.issue_number}${args.detail_level ? `\n- detailLevel: "${args.detail_level}"` : ''}${args.use_explorer === 'false' ? '\n- useExplorer: false' : ''} + +The tool will: +1. Fetch the issue details +2. Find relevant code using semantic search +3. Break down the work into specific tasks +4. Estimate complexity and dependencies + +Review the plan and suggest any modifications or improvements.`, + }, + }, + ], + }) + ); + + // Quick Search + this.register( + { + name: 'quick-search', + description: 'Quick semantic search across the codebase', + arguments: [ + { + name: 'query', + description: 'What to search for', + required: true, + }, + { + name: 'limit', + description: 'Number of results (default: 10)', + required: false, + }, + ], + }, + (args) => ({ + description: `Search for: ${args.query}`, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Search the codebase for "${args.query}": + +Use dev_search with: +- query: "${args.query}" +- format: "verbose"${args.limit ? `\n- limit: ${args.limit}` : ''} + +Summarize the findings and their relevance.`, + }, + }, + ], + }) + ); + } + + /** + * Register a prompt + */ + private register( + definition: PromptDefinition, + generator: (args: Record) => Prompt + ): void { + this.prompts.set(definition.name, { definition, generator }); + } + + /** + * Get all prompt definitions + */ + listPrompts(): PromptDefinition[] { + return Array.from(this.prompts.values()).map((p) => p.definition); + } + + /** + * Get a specific prompt with arguments + */ + getPrompt(name: string, args: Record = {}): Prompt | null { + const prompt = this.prompts.get(name); + if (!prompt) { + return null; + } + + // Validate required arguments + const required = prompt.definition.arguments?.filter((arg) => arg.required) || []; + for (const arg of required) { + if (!args[arg.name]) { + throw new Error(`Missing required argument: ${arg.name}`); + } + } + + return prompt.generator(args); + } + + /** + * Check if a prompt exists + */ + hasPrompt(name: string): boolean { + return this.prompts.has(name); + } +}