├── .github └── workflows │ ├── npm-publish.yml │ └── npm-test.yml ├── .gitignore ├── .npmrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ ├── execa.js └── fs │ └── promises.js ├── in_action.png ├── jest.config.js ├── jest.setup.ts ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── models │ │ ├── claude.test.ts │ │ └── integration.test.ts │ ├── server │ │ └── mcp-server.test.ts │ ├── services │ │ └── summarization.test.ts │ └── test-files │ │ ├── test1.txt │ │ └── test2.txt ├── index.ts ├── models │ ├── anthropic.ts │ ├── gemini.ts │ ├── index.ts │ ├── openai-compatible.ts │ ├── openai.ts │ └── prompts.ts ├── server │ └── mcp-server.ts ├── services │ └── summarization.ts └── types │ └── models.ts └── tsconfig.json /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22' 18 | registry-url: 'https://registry.npmjs.org' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run tests 24 | run: npm run test:ci 25 | 26 | - name: Build 27 | run: npm run build 28 | 29 | - name: Publish to NPM 30 | run: npm publish --access public 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/npm-test.yml: -------------------------------------------------------------------------------- 1 | name: NPM Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '22' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run tests 24 | run: npm run test:ci -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Build output 8 | build/ 9 | dist/ 10 | *.tsbuildinfo 11 | 12 | # Environment variables and secrets 13 | .env 14 | *.env 15 | .env.* 16 | 17 | # IDE and editor files 18 | .idea/ 19 | .vscode/ 20 | *.swp 21 | *.swo 22 | *~ 23 | 24 | # OS files 25 | .DS_Store 26 | Thumbs.db 27 | 28 | .env 29 | 30 | build/ 31 | build/* 32 | coverage/ 33 | coverage/* -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | always-auth=true -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to AI Agent Summarization Tools 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Becoming a maintainer 10 | 11 | ## We Develop with Github 12 | We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | ## Pull Requests 15 | 1. Fork the repo and create your branch from `main`. 16 | 2. If you've added code that should be tested, add tests. 17 | 3. If you've changed APIs, update the documentation. 18 | 4. Ensure the test suite passes. 19 | 5. Make sure your code lints. 20 | 6. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 24 | 25 | ## Report bugs using Github's [issue tracker](../../issues) 26 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](../../issues/new); it's that easy! 27 | 28 | ## Write bug reports with detail, background, and sample code 29 | 30 | **Great Bug Reports** tend to have: 31 | 32 | - A quick summary and/or background 33 | - Steps to reproduce 34 | - Be specific! 35 | - Give sample code if you can. 36 | - What you expected would happen 37 | - What actually happens 38 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 39 | 40 | ## License 41 | By contributing, you agree that your contributions will be licensed under its MIT License. 42 | 43 | ## References 44 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Remi Sebastian Kits 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Summarization Functions 4 | 5 | ### Intelligent text summarization for the Model Context Protocol 6 | 7 | [Features](#features) • 8 | [AI Agent Integration](#ai-agent-integration) • 9 | [Installation](#installation) • 10 | [Usage](#usage) 11 | 12 | [![smithery badge](https://smithery.ai/badge/mcp-summarization-functions)](https://smithery.ai/server/mcp-summarization-functions) 13 | [![npm version](https://badge.fury.io/js/mcp-summarization-functions.svg)](https://www.npmjs.com/package/mcp-summarization-functions) 14 | 15 |
16 | 17 | --- 18 | 19 | ## Overview 20 | 21 | A powerful MCP server that provides intelligent summarization capabilities through a clean, extensible architecture. Built with modern TypeScript and designed for seamless integration with AI workflows. 22 | 23 | ## Installation 24 | 25 | ### Installing via Smithery 26 | 27 | To install Summarization Functions for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-summarization-functions): 28 | 29 | ```bash 30 | npx -y @smithery/cli install mcp-summarization-functions --client claude 31 | ``` 32 | 33 | ```bash 34 | npm i mcp-summarization-functions 35 | ``` 36 | 37 | ## AI Agent Integration 38 | 39 | This MCP server was primarily developed to enhance the performance and reliability of AI agents like Roo Cline and Cline. It addresses a critical challenge in AI agent operations: context window management. 40 | 41 | ### Context Window Optimization 42 | 43 | AI agents frequently encounter situations where their context window gets rapidly filled with large outputs from: 44 | - Command execution results 45 | - File content readings 46 | - Directory listings 47 | - API responses 48 | - Error messages and stack traces 49 | 50 | This server helps maintain efficient context usage by: 51 | 1. Providing concise, relevant summaries instead of full content 52 | 2. Storing full content for reference when needed 53 | 3. Offering focused analysis based on specific needs (security, API surface, etc.) 54 | 4. Supporting multiple output formats for optimal context utilization 55 | 56 | ### Benefits for AI Agents 57 | 58 | - **Reduced Failure Rates**: By preventing context window overflow 59 | - **Improved Response Quality**: Through focused, relevant summaries 60 | - **Enhanced Efficiency**: By maintaining important context while reducing noise 61 | - **Better Resource Management**: Through intelligent content caching and retrieval 62 | - **Flexible Integration**: Supporting multiple AI providers and configuration options 63 | 64 | ### Recommended AI Agent Prompt 65 | 66 | When integrating with AI agents, include the following in your agent's instructions: 67 | 68 | ``` 69 | # CONTEXT MANAGEMENT 70 | 71 | You have access to summarization functions through the MCP server. These functions are NOT optional - you MUST use them for ALL potentially large outputs to prevent context overflow: 72 | 73 | MANDATORY SUMMARIZATION: 74 | - You MUST ALWAYS use summarization functions for: 75 | - ANY first time file reading operations (unless you are CERTAIN its small and you are going to edit it) 76 | - ALL command execution outputs 77 | - EVERY directory analysis 78 | - ANY API responses or error logs 79 | - ANY output that could be large 80 | 81 | NEVER attempt to process raw output directly - ALWAYS use the appropriate summarization function: 82 | • For commands: summarize_command 83 | • For files: summarize_files 84 | • For directories: summarize_directory 85 | • For other text: summarize_text 86 | 87 | ALWAYS utilize available features: 88 | • Specify hints for focused analysis 89 | • Choose appropriate output formats 90 | • Use content IDs to access full details only when absolutely necessary 91 | 92 | There is NO NEED to process perfect or complete output. Summarized content is ALWAYS preferred over raw data. When in doubt, use summarization. 93 | ``` 94 | 95 | Summarization in action on the Ollama repository (Gemini 2.0 Flash summarization, Claude 3.5 agent) 96 | 97 | ![alt text](in_action.png) 98 | 99 | 100 | ## Features 101 | 102 | - **Command Output Summarization** 103 | Execute commands and get concise summaries of their output 104 | 105 | - **File Content Analysis** 106 | Summarize single or multiple files while maintaining technical accuracy 107 | 108 | - **Directory Structure Understanding** 109 | Get clear overviews of complex directory structures 110 | 111 | - **Flexible Model Support** 112 | Use models from different providers 113 | 114 | - **AI Agent Context Optimization** 115 | Prevent context window overflow and improve AI agent performance through intelligent summarization 116 | 117 | ## Configuration 118 | 119 | The server supports multiple AI providers through environment variables: 120 | 121 | ### Required Environment Variables 122 | 123 | - `PROVIDER`: AI provider to use. Supported values: 124 | - `ANTHROPIC` - Claude models from Anthropic 125 | - `OPENAI` - GPT models from OpenAI 126 | - `OPENAI-COMPATIBLE` - OpenAI-compatible APIs (e.g. Azure) 127 | - `GOOGLE` - Gemini models from Google 128 | - `API_KEY`: API key for the selected provider 129 | 130 | ### Optional Environment Variables 131 | 132 | - `MODEL_ID`: Specific model to use (defaults to provider's standard model) 133 | - `PROVIDER_BASE_URL`: Custom API endpoint for OpenAI-compatible providers 134 | - `MAX_TOKENS`: Maximum tokens for model responses (default: 1024) 135 | - `SUMMARIZATION_CHAR_THRESHOLD`: Character count threshold for when to summarize (default: 512) 136 | - `SUMMARIZATION_CACHE_MAX_AGE`: Cache duration in milliseconds (default: 3600000 - 1 hour) 137 | - `MCP_WORKING_DIR` - fallback directory for trying to find files with relative paths from 138 | 139 | ### Example Configurations 140 | 141 | ```bash 142 | # Anthropic Configuration 143 | PROVIDER=ANTHROPIC 144 | API_KEY=your-anthropic-key 145 | MODEL_ID=claude-3-5-sonnet-20241022 146 | 147 | # OpenAI Configuration 148 | PROVIDER=OPENAI 149 | API_KEY=your-openai-key 150 | MODEL_ID=gpt-4-turbo-preview 151 | 152 | # Azure OpenAI Configuration 153 | PROVIDER=OPENAI-COMPATIBLE 154 | API_KEY=your-azure-key 155 | PROVIDER_BASE_URL=https://your-resource.openai.azure.com 156 | MODEL_ID=your-deployment-name 157 | 158 | # Google Configuration 159 | PROVIDER=GOOGLE 160 | API_KEY=your-google-key 161 | MODEL_ID=gemini-2.0-flash-exp 162 | ``` 163 | 164 | ## Usage 165 | 166 | Add the server to your MCP configuration file: 167 | 168 | ```json 169 | { 170 | "mcpServers": { 171 | "MUST_USE_summarization": { 172 | "command": "node", 173 | "args": ["path/to/summarization-functions/build/index.js"], 174 | "env": { 175 | "PROVIDER": "ANTHROPIC", 176 | "API_KEY": "your-api-key", 177 | "MODEL_ID": "claude-3-5-sonnet-20241022", 178 | "MCP_WORKING_DIR": "default_working_directory" 179 | } 180 | } 181 | } 182 | } 183 | ``` 184 | 185 | ### Available Functions 186 | 187 | The server provides the following summarization tools: 188 | 189 | #### `summarize_command` 190 | Execute and summarize command output. 191 | ```typescript 192 | { 193 | // Required 194 | command: string, // Command to execute 195 | cwd: string, // Working directory for command execution 196 | 197 | // Optional 198 | hint?: string, // Focus area: "security_analysis" | "api_surface" | "error_handling" | "dependencies" | "type_definitions" 199 | output_format?: string // Format: "text" | "json" | "markdown" | "outline" (default: "text") 200 | } 201 | ``` 202 | 203 | #### `summarize_files` 204 | Summarize file contents. 205 | ```typescript 206 | { 207 | // Required 208 | paths: string[], // Array of file paths to summarize (relative to cwd) 209 | cwd: string, // Working directory for resolving file paths 210 | 211 | // Optional 212 | hint?: string, // Focus area: "security_analysis" | "api_surface" | "error_handling" | "dependencies" | "type_definitions" 213 | output_format?: string // Format: "text" | "json" | "markdown" | "outline" (default: "text") 214 | } 215 | ``` 216 | 217 | #### `summarize_directory` 218 | Get directory structure overview. 219 | ```typescript 220 | { 221 | // Required 222 | path: string, // Directory path to summarize (relative to cwd) 223 | cwd: string, // Working directory for resolving directory path 224 | 225 | // Optional 226 | recursive?: boolean, // Whether to include subdirectories. Safe for deep directories 227 | hint?: string, // Focus area: "security_analysis" | "api_surface" | "error_handling" | "dependencies" | "type_definitions" 228 | output_format?: string // Format: "text" | "json" | "markdown" | "outline" (default: "text") 229 | } 230 | ``` 231 | 232 | #### `summarize_text` 233 | Summarize arbitrary text content. 234 | ```typescript 235 | { 236 | // Required 237 | content: string, // Text content to summarize 238 | type: string, // Type of content (e.g., "log output", "API response") 239 | 240 | // Optional 241 | hint?: string, // Focus area: "security_analysis" | "api_surface" | "error_handling" | "dependencies" | "type_definitions" 242 | output_format?: string // Format: "text" | "json" | "markdown" | "outline" (default: "text") 243 | } 244 | ``` 245 | 246 | #### `get_full_content` 247 | Retrieve the full content for a given summary ID. 248 | ```typescript 249 | { 250 | // Required 251 | id: string // ID of the stored content 252 | } 253 | ``` 254 | 255 | ## License 256 | 257 | MIT 258 | -------------------------------------------------------------------------------- /__mocks__/execa.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const mockExecaFn = jest.fn().mockImplementation(async (command, options = {}) => ({ 4 | stdout: 'test output', 5 | stderr: '', 6 | failed: false, 7 | exitCode: 0, 8 | command, 9 | escapedCommand: command, 10 | isCanceled: false, 11 | killed: false, 12 | timedOut: false, 13 | stdio: ['pipe', 'pipe', 'pipe'] 14 | })); 15 | 16 | // Create the execa function with the execa property 17 | const execa = mockExecaFn; 18 | execa.execa = mockExecaFn; 19 | 20 | // Export both the function and the object 21 | module.exports = execa; 22 | module.exports.execa = mockExecaFn; 23 | module.exports.default = execa; -------------------------------------------------------------------------------- /__mocks__/fs/promises.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = jest.createMockFromModule('fs'); 4 | 5 | // Mock Dirent class 6 | class MockDirent { 7 | constructor(name, isDir) { 8 | this.name = name; 9 | this._isDir = isDir; 10 | } 11 | isDirectory() { return this._isDir; } 12 | isFile() { return !this._isDir; } 13 | isBlockDevice() { return false; } 14 | isCharacterDevice() { return false; } 15 | isFIFO() { return false; } 16 | isSocket() { return false; } 17 | isSymbolicLink() { return false; } 18 | } 19 | 20 | // Create mock functions with Jest mock functionality 21 | const mockReadFile = jest.fn().mockImplementation(async () => 'test file content'); 22 | const mockReaddir = jest.fn().mockImplementation(async () => [ 23 | new MockDirent('test.txt', false), 24 | new MockDirent('testDir', true) 25 | ]); 26 | 27 | // Add Jest mock functions 28 | mockReadFile.mockResolvedValue = jest.fn().mockImplementation((value) => { 29 | return mockReadFile.mockImplementation(async () => value); 30 | }); 31 | 32 | mockReadFile.mockRejectedValue = jest.fn().mockImplementation((error) => { 33 | return mockReadFile.mockImplementation(async () => { throw error; }); 34 | }); 35 | 36 | mockReadFile.mockResolvedValueOnce = jest.fn().mockImplementation((value) => { 37 | return mockReadFile.mockImplementationOnce(async () => value); 38 | }); 39 | 40 | mockReadFile.mockRejectedValueOnce = jest.fn().mockImplementation((error) => { 41 | return mockReadFile.mockImplementationOnce(async () => { throw error; }); 42 | }); 43 | 44 | // Add the same mock functions to readdir 45 | mockReaddir.mockResolvedValue = jest.fn().mockImplementation((value) => { 46 | return mockReaddir.mockImplementation(async () => value); 47 | }); 48 | 49 | mockReaddir.mockRejectedValue = jest.fn().mockImplementation((error) => { 50 | return mockReaddir.mockImplementation(async () => { throw error; }); 51 | }); 52 | 53 | mockReaddir.mockResolvedValueOnce = jest.fn().mockImplementation((value) => { 54 | return mockReaddir.mockImplementationOnce(async () => value); 55 | }); 56 | 57 | mockReaddir.mockRejectedValueOnce = jest.fn().mockImplementation((error) => { 58 | return mockReaddir.mockImplementationOnce(async () => { throw error; }); 59 | }); 60 | 61 | fs.readFile = mockReadFile; 62 | fs.readdir = mockReaddir; 63 | 64 | module.exports = fs; 65 | -------------------------------------------------------------------------------- /in_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Braffolk/mcp-summarization-functions/0e61e5a91d4f4a7630741d6929c12862e14c8884/in_action.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | extensionsToTreatAsEsm: ['.ts'], 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | transform: { 10 | '^.+\\.tsx?$': [ 11 | 'ts-jest', 12 | { 13 | useESM: true, 14 | }, 15 | ], 16 | }, 17 | collectCoverageFrom: [ 18 | 'src/**/*.ts', 19 | '!src/__tests__/**', 20 | '!src/types/**', 21 | '!src/index.ts' 22 | ], 23 | coverageThreshold: { 24 | global: { 25 | branches: 50, 26 | functions: 70, 27 | lines: 60, 28 | statements: 60 29 | } 30 | }, 31 | testTimeout: 10000, 32 | setupFilesAfterEnv: ['./jest.setup.ts'] 33 | }; -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | // Mock console.error to avoid noise in test output 4 | jest.spyOn(console, 'error').mockImplementation(() => {}); 5 | 6 | // Reset all mocks after each test 7 | afterEach(() => { 8 | jest.clearAllMocks(); 9 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-summarization-functions", 3 | "version": "0.1.5", 4 | "description": "Provides summarised output from various actions that could otherwise eat up tokens and cause crashes", 5 | "type": "module", 6 | "main": "build/index.js", 7 | "bin": { 8 | "mcp-summarization-functions": "build/index.js" 9 | }, 10 | "engines": { 11 | "node": ">=22.0.0" 12 | }, 13 | "scripts": { 14 | "preinstall": "node -e \"if (process.versions.node < '22.0.0') { console.error('Requires Node.js 22 or higher. Current version: ' + process.versions.node); process.exit(1); }\"", 15 | "build": "tsc", 16 | "postbuild": "node -e \"if (process.platform !== 'win32') { const fs = require('fs'); const path = require('path'); function chmodRecursive(dir) { fs.readdirSync(dir, { withFileTypes: true }).forEach(dirent => { const fullPath = path.join(dir, dirent.name); if (dirent.isDirectory()) { chmodRecursive(fullPath); } else if (dirent.name.endsWith('.js')) { console.log('Setting chmod 755 on:', fullPath); fs.chmodSync(fullPath, '755'); } }); } chmodRecursive('build'); }\"", 17 | "start": "node build/index.js", 18 | "watch": "tsc -w", 19 | "test": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest", 20 | "test:watch": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --watch", 21 | "test:coverage": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --coverage", 22 | "test:ci": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --ci --coverage --maxWorkers=2" 23 | }, 24 | "dependencies": { 25 | "@anthropic-ai/sdk": "^0.35.0", 26 | "@modelcontextprotocol/sdk": "^1.0.3", 27 | "dotenv": "^16.4.7", 28 | "execa": "^9.5.2", 29 | "isomorphic-fetch":"^3.0.0", 30 | "uuid": "^11.0.3" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "^29.5.14", 34 | "@types/node": "^20.0.0", 35 | "@types/uuid": "^9.0.0", 36 | "@types/isomorphic-fetch": "^0.0.39", 37 | "node-fetch": "^3.3.2", 38 | "jest": "^29.7.0", 39 | "ts-jest": "^29.2.5", 40 | "typescript": "^5.7.2" 41 | }, 42 | "files": [ 43 | "build/**/*" 44 | ], 45 | "publishConfig": { 46 | "access": "public" 47 | }, 48 | "repository": { 49 | "type": "git", 50 | "url": "git+https://github.com/Braffolk/MCP-summarization-functions.git" 51 | }, 52 | "keywords": [ 53 | "mcp", 54 | "summarization", 55 | "model-context-protocol" 56 | ], 57 | "author": "Remi Sebastian Kits", 58 | "license": "MIT" 59 | } 60 | -------------------------------------------------------------------------------- /src/__tests__/models/claude.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect, beforeEach } from '@jest/globals'; 2 | import { config } from 'dotenv'; 3 | //import nodeFetch from 'node-fetch'; 4 | import nodeFetch from 'isomorphic-fetch'; 5 | config(); 6 | 7 | // Mock fetch with proper types 8 | const mockFetch = jest.fn() as jest.MockedFunction; 9 | global.fetch = mockFetch; 10 | 11 | 12 | // Mock successful response 13 | const mockSuccessResponse = { 14 | content: [{ text: 'Mocked summary response', type: 'text' }], 15 | model: 'claude-3-5-sonnet-20241022', 16 | role: 'assistant' 17 | }; 18 | 19 | // Import after mocking 20 | import { AnthropicModel, createAnthropicModel } from '../../models/anthropic.js'; 21 | import { ModelConfig, SummarizationOptions } from '../../types/models.js'; 22 | import { constructPrompt, getBaseSummarizationInstructions, getFinalInstructions } from '../../models/prompts.js'; 23 | 24 | describe('AnthropicModel', () => { 25 | const MOCK_API_KEY = 'dummy-key'; 26 | const REAL_API_KEY = process.env.ANTHROPIC_API_KEY || ''; 27 | if (!REAL_API_KEY) { 28 | console.warn('Skipping integration tests. Set ANTHROPIC_API_KEY to run integration tests'); 29 | } 30 | let model: AnthropicModel; 31 | 32 | beforeEach(() => { 33 | jest.clearAllMocks(); 34 | mockFetch.mockResolvedValue({ 35 | ok: true, 36 | json: async () => mockSuccessResponse 37 | } as Response); 38 | model = createAnthropicModel({ 39 | apiKey: MOCK_API_KEY, 40 | fetch: mockFetch 41 | }) as AnthropicModel; 42 | }); 43 | 44 | describe('Unit Tests', () => { 45 | describe('initialization', () => { 46 | it('should initialize with default config values', async () => { 47 | await model.initialize({ apiKey: MOCK_API_KEY }); 48 | }); 49 | 50 | it('should initialize with custom config values', async () => { 51 | const config = { 52 | apiKey: MOCK_API_KEY, 53 | model: 'custom-model', 54 | maxTokens: 2048 55 | }; 56 | await model.initialize(config); 57 | }); 58 | 59 | it('should throw error if API key is missing', async () => { 60 | await expect(model.initialize({} as ModelConfig)) 61 | .rejects.toThrow('API key is required for Anthropic model'); 62 | }); 63 | 64 | it('should validate model name', async () => { 65 | await expect( 66 | model.initialize({ 67 | apiKey: MOCK_API_KEY, 68 | model: ' ' 69 | }) 70 | ).rejects.toThrow('Invalid model name'); 71 | }); 72 | 73 | it('should validate max tokens', async () => { 74 | await expect( 75 | model.initialize({ 76 | apiKey: MOCK_API_KEY, 77 | maxTokens: 0 78 | }) 79 | ).rejects.toThrow('Invalid max tokens value'); 80 | 81 | await expect( 82 | model.initialize({ 83 | apiKey: MOCK_API_KEY, 84 | maxTokens: -1 85 | }) 86 | ).rejects.toThrow('Invalid max tokens value'); 87 | 88 | // Test non-integer values 89 | await expect( 90 | model.initialize({ 91 | apiKey: MOCK_API_KEY, 92 | maxTokens: 1.5 93 | }) 94 | ).rejects.toThrow('Invalid max tokens value'); 95 | }); 96 | }); 97 | 98 | describe('summarization', () => { 99 | beforeEach(async () => { 100 | await model.initialize({ apiKey: MOCK_API_KEY }); 101 | }); 102 | 103 | it('should throw error if model is not initialized', async () => { 104 | const uninitializedModel = createAnthropicModel(); 105 | await expect( 106 | uninitializedModel.summarize('content', 'text') 107 | ).rejects.toThrow('Anthropic model not initialized'); 108 | }); 109 | 110 | it('should handle network errors', async () => { 111 | mockFetch.mockRejectedValue(new Error('Network error')); 112 | 113 | await expect( 114 | model.summarize('content', 'text') 115 | ).rejects.toThrow('Anthropic summarization failed: API error: Connection error.'); 116 | }); 117 | 118 | }); 119 | 120 | describe('cleanup', () => { 121 | it('should clean up resources', async () => { 122 | await model.initialize({ apiKey: MOCK_API_KEY }); 123 | await model.cleanup(); 124 | 125 | await expect( 126 | model.summarize('content', 'text') 127 | ).rejects.toThrow('Anthropic model not initialized'); 128 | }); 129 | }); 130 | 131 | describe('factory function', () => { 132 | it('should create a new instance', () => { 133 | const instance = createAnthropicModel(); 134 | expect(instance).toBeInstanceOf(AnthropicModel); 135 | }); 136 | }); 137 | }); 138 | 139 | describe('Integration Tests', () => { 140 | // Only run integration tests if we have a real API key 141 | (REAL_API_KEY ? describe : describe.skip)('with real API', () => { 142 | beforeEach(async () => { 143 | // Use node-fetch for integration tests 144 | global.fetch = nodeFetch as unknown as typeof fetch; 145 | model = createAnthropicModel() as AnthropicModel; 146 | await model.initialize({ apiKey: REAL_API_KEY }); 147 | }); 148 | 149 | afterEach(() => { 150 | // Restore mock 151 | global.fetch = mockFetch as unknown as typeof fetch; 152 | }); 153 | 154 | it('should summarize content with real API', async () => { 155 | const content = 'This is a test content that needs to be summarized.'; 156 | const summary = await model.summarize(content, 'text'); 157 | expect(summary).toBeTruthy(); 158 | expect(typeof summary).toBe('string'); 159 | }, 10000); // Increase timeout for API call 160 | 161 | it('should summarize with hint and output format using real API', async () => { 162 | const content = ` 163 | function authenticate(user, password) { 164 | if (password === 'admin123') { 165 | return true; 166 | } 167 | return false; 168 | } 169 | `; 170 | const options: SummarizationOptions = { 171 | hint: 'security_analysis', 172 | output_format: 'json' 173 | }; 174 | const summary = await model.summarize(content, 'code', options); 175 | expect(summary).toBeTruthy(); 176 | expect(typeof summary).toBe('string'); 177 | // Should be valid JSON since we requested JSON format 178 | expect(() => JSON.parse(summary)).not.toThrow(); 179 | }, 10000); // Increase timeout for API call 180 | }); 181 | }); 182 | }); -------------------------------------------------------------------------------- /src/__tests__/models/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 2 | import { config } from 'dotenv'; 3 | import nodeFetch from 'node-fetch'; 4 | config(); 5 | 6 | // Import models 7 | import { createAnthropicModel } from '../../models/anthropic.js'; 8 | import { OpenAIModel } from '../../models/openai.js'; 9 | import { GeminiModel } from '../../models/gemini.js'; 10 | import { SummarizationModel, SummarizationOptions } from '../../types/models.js'; 11 | 12 | // Get API keys from environment 13 | const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ''; 14 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ''; 15 | const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ''; 16 | 17 | describe('Model Integration Tests', () => { 18 | // Use node-fetch for all integration tests 19 | beforeEach(() => { 20 | global.fetch = nodeFetch as unknown as typeof fetch; 21 | }); 22 | 23 | describe('Anthropic Model', () => { 24 | let model: SummarizationModel; 25 | 26 | beforeEach(async () => { 27 | model = createAnthropicModel(); 28 | if (ANTHROPIC_API_KEY) { 29 | await model.initialize({ apiKey: ANTHROPIC_API_KEY }); 30 | } 31 | }); 32 | 33 | (ANTHROPIC_API_KEY ? it : it.skip)('should summarize text content', async () => { 34 | const content = 'This is a test content that needs to be summarized. It contains multiple sentences and should be processed by the model to create a concise summary.'; 35 | const summary = await model.summarize(content, 'text'); 36 | expect(summary).toBeTruthy(); 37 | expect(typeof summary).toBe('string'); 38 | }, 10000); 39 | 40 | (ANTHROPIC_API_KEY ? it : it.skip)('should summarize code with security analysis', async () => { 41 | const content = ` 42 | function authenticate(user, password) { 43 | if (password === 'admin123') { 44 | return true; 45 | } 46 | return false; 47 | } 48 | `; 49 | const options: SummarizationOptions = { 50 | hint: 'security_analysis', 51 | output_format: 'json' 52 | }; 53 | const summary = await model.summarize(content, 'code', options); 54 | expect(summary).toBeTruthy(); 55 | expect(typeof summary).toBe('string'); 56 | 57 | // Verify the response contains security-focused analysis 58 | expect(summary.toLowerCase()).toContain('security'); 59 | }, 10000); 60 | }); 61 | 62 | describe('OpenAI Model', () => { 63 | let model: SummarizationModel; 64 | 65 | beforeEach(async () => { 66 | model = new OpenAIModel(); 67 | if (OPENAI_API_KEY) { 68 | await model.initialize({ 69 | apiKey: OPENAI_API_KEY, 70 | model: 'gpt-3.5-turbo' 71 | }); 72 | } 73 | }); 74 | 75 | (OPENAI_API_KEY ? it : it.skip)('should summarize text content', async () => { 76 | const content = 'This is a test content that needs to be summarized. It contains multiple sentences and should be processed by the model to create a concise summary.'; 77 | const summary = await model.summarize(content, 'text'); 78 | expect(summary).toBeTruthy(); 79 | expect(typeof summary).toBe('string'); 80 | }, 10000); 81 | 82 | (OPENAI_API_KEY ? it : it.skip)('should summarize code with type definitions', async () => { 83 | const content = ` 84 | interface User { 85 | id: string; 86 | name: string; 87 | email: string; 88 | roles: string[]; 89 | } 90 | 91 | class UserService { 92 | private users: User[] = []; 93 | 94 | addUser(user: User) { 95 | this.users.push(user); 96 | } 97 | 98 | findUserById(id: string): User | undefined { 99 | return this.users.find(u => u.id === id); 100 | } 101 | } 102 | `; 103 | const options: SummarizationOptions = { 104 | hint: 'type_definitions', 105 | output_format: 'json' 106 | }; 107 | const summary = await model.summarize(content, 'code', options); 108 | expect(summary).toBeTruthy(); 109 | expect(typeof summary).toBe('string'); 110 | 111 | // Verify the response contains type-related analysis 112 | const typePhrases = ['interface', 'property', 'properties', 'class']; 113 | const hasTypeRelatedTerms = typePhrases.some(phrase => 114 | summary.toLowerCase().includes(phrase) 115 | ); 116 | expect(hasTypeRelatedTerms).toBe(true); 117 | }, 10000); 118 | }); 119 | 120 | describe('Gemini Model', () => { 121 | let model: SummarizationModel; 122 | 123 | beforeEach(async () => { 124 | model = new GeminiModel(); 125 | if (GEMINI_API_KEY) { 126 | await model.initialize({ apiKey: GEMINI_API_KEY }); 127 | } 128 | }); 129 | 130 | (GEMINI_API_KEY ? it : it.skip)('should summarize text content', async () => { 131 | const content = 'This is a test content that needs to be summarized. It contains multiple sentences and should be processed by the model to create a concise summary.'; 132 | const summary = await model.summarize(content, 'text'); 133 | expect(summary).toBeTruthy(); 134 | expect(typeof summary).toBe('string'); 135 | }, 10000); 136 | 137 | (GEMINI_API_KEY ? it : it.skip)('should summarize code with API surface analysis', async () => { 138 | const content = ` 139 | export class DataProcessor { 140 | constructor(private config: ProcessorConfig) {} 141 | 142 | async processData(input: RawData): Promise { 143 | // Implementation 144 | } 145 | 146 | validateInput(data: RawData): ValidationResult { 147 | // Implementation 148 | } 149 | 150 | @Deprecated('Use processData instead') 151 | async oldProcessMethod(data: any): Promise { 152 | // Legacy implementation 153 | } 154 | } 155 | `; 156 | const options: SummarizationOptions = { 157 | hint: 'api_surface', 158 | output_format: 'json' 159 | }; 160 | const summary = await model.summarize(content, 'code', options); 161 | expect(summary).toBeTruthy(); 162 | expect(typeof summary).toBe('string'); 163 | 164 | // Clean the response of any markdown/code block syntax before parsing JSON 165 | const cleanJson = summary 166 | .replace(/```json\n?/g, '') // Remove ```json 167 | .replace(/```\n?/g, '') // Remove closing ``` 168 | .trim(); // Remove extra whitespace 169 | 170 | expect(() => JSON.parse(cleanJson)).not.toThrow(); 171 | }, 10000); 172 | }); 173 | }); -------------------------------------------------------------------------------- /src/__tests__/server/mcp-server.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 2 | import { McpServer } from '../../server/mcp-server.js'; 3 | import { SummarizationService } from '../../services/summarization.js'; 4 | import { ErrorCode, McpError, ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; 5 | import { SummarizationModel } from '../../types/models.js'; 6 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 7 | import path from 'path'; 8 | 9 | // Simple mock model for testing 10 | class MockModel implements SummarizationModel { 11 | async initialize(): Promise {} 12 | async summarize(content: string, type: string): Promise { 13 | // For directory listings, return the actual content 14 | if (type === 'directory listing') { 15 | return content; 16 | } 17 | return `Summarized ${type}`; 18 | } 19 | async cleanup(): Promise {} 20 | } 21 | 22 | describe('McpServer', () => { 23 | let server: McpServer; 24 | let summarizationService: SummarizationService; 25 | let mockServer: { 26 | handlers: Map; 27 | setRequestHandler: (schema: any, handler: Function) => void; 28 | }; 29 | 30 | beforeEach(async () => { 31 | // Setup mock server 32 | mockServer = { 33 | handlers: new Map(), 34 | setRequestHandler: function(schema: any, handler: Function) { 35 | const method = schema === ListToolsRequestSchema ? 'list_tools' : 36 | schema === CallToolRequestSchema ? 'call_tool' : 37 | 'unknown'; 38 | this.handlers.set(method, handler); 39 | } 40 | }; 41 | 42 | // Mock Server constructor 43 | jest.spyOn(Server.prototype, 'setRequestHandler') 44 | .mockImplementation((schema, handler) => mockServer.setRequestHandler(schema, handler)); 45 | 46 | jest.spyOn(Server.prototype, 'connect').mockResolvedValue(); 47 | jest.spyOn(Server.prototype, 'close').mockResolvedValue(); 48 | 49 | // Create services 50 | summarizationService = new SummarizationService(new MockModel(), { 51 | model: { apiKey: 'test-key' }, 52 | charThreshold: 100, 53 | cacheMaxAge: 1000 54 | }); 55 | 56 | server = new McpServer(summarizationService); 57 | await server.start(); // This will register the handlers 58 | }); 59 | 60 | afterEach(async () => { 61 | await server.cleanup(); 62 | }); 63 | 64 | const getHandler = (name: string): Function => { 65 | const handler = mockServer.handlers.get(name); 66 | if (!handler) { 67 | throw new Error(`Handler not found: ${name}`); 68 | } 69 | return handler; 70 | }; 71 | 72 | describe('Tool Registration', () => { 73 | it('should register all tools on server initialization', async () => { 74 | const response = await getHandler('list_tools')({ method: 'list_tools' }, {}); 75 | 76 | expect(response.tools).toHaveLength(5); 77 | expect(response.tools.map((t: { name: string }) => t.name)).toEqual([ 78 | 'summarize_command', 79 | 'summarize_files', 80 | 'summarize_directory', 81 | 'summarize_text', 82 | 'get_full_content' 83 | ]); 84 | }); 85 | }); 86 | 87 | describe('Tool Execution', () => { 88 | const callTool = async (name: string, args: any) => { 89 | return getHandler('call_tool')({ 90 | method: 'call_tool', 91 | params: { name, arguments: args } 92 | }, {}); 93 | }; 94 | 95 | describe('summarize_command', () => { 96 | it('should execute and return command output', async () => { 97 | const response = await callTool('summarize_command', { 98 | command: 'echo "Hello, World!"' 99 | }); 100 | 101 | expect(response.content[0].text).toBe('Hello, World!'); 102 | }); 103 | 104 | it('should include stderr in output when present', async () => { 105 | const response = await callTool('summarize_command', { 106 | command: 'echo "output" && echo "error" >&2' 107 | }); 108 | 109 | expect(response.content[0].text).toBe('output\nError: error'); 110 | }); 111 | }); 112 | 113 | describe('summarize_files', () => { 114 | const testFilesDir = path.join(process.cwd(), 'src', '__tests__', 'test-files'); 115 | 116 | it('should read and return file contents', async () => { 117 | const response = await callTool('summarize_files', { 118 | paths: ['test1.txt'], 119 | cwd: testFilesDir 120 | }); 121 | 122 | expect(response.content[0].text).toContain('This is test file 1'); 123 | }); 124 | 125 | it('should handle multiple files', async () => { 126 | const response = await callTool('summarize_files', { 127 | paths: [ 128 | 'test1.txt', 129 | 'test2.txt' 130 | ], 131 | cwd: testFilesDir 132 | }); 133 | 134 | expect(response.content[0].text).toContain('This is test file 1'); 135 | expect(response.content[0].text).toContain('This is test file 2'); 136 | }); 137 | }); 138 | 139 | describe('summarize_directory', () => { 140 | const testFilesDir = path.join(process.cwd(), 'src', '__tests__', 'test-files'); 141 | 142 | it('should list directory contents', async () => { 143 | const response = await callTool('summarize_directory', { 144 | cwd: testFilesDir, 145 | path: '.' 146 | }); 147 | 148 | // Check for the presence of both test files in the output 149 | const text = response.content[0].text; 150 | expect(text).toContain('test1.txt'); 151 | expect(text).toContain('test2.txt'); 152 | // Verify it's a proper directory listing 153 | expect(text).toMatch(/Summary \(full content ID: [\w-]+\):/); 154 | }); 155 | }); 156 | 157 | describe('summarize_text', () => { 158 | it('should summarize long text', async () => { 159 | const longText = 'a'.repeat(200); 160 | const response = await callTool('summarize_text', { 161 | content: longText, 162 | type: 'test' 163 | }); 164 | 165 | expect(response.content[0].text).toContain('Summarized test'); 166 | }); 167 | 168 | it('should return original text if short', async () => { 169 | const shortText = 'short text'; 170 | const response = await callTool('summarize_text', { 171 | content: shortText, 172 | type: 'test' 173 | }); 174 | 175 | expect(response.content[0].text).toBe(shortText); 176 | }); 177 | }); 178 | 179 | describe('get_full_content', () => { 180 | it('should throw error for invalid ID', async () => { 181 | await expect(callTool('get_full_content', { 182 | id: 'invalid' 183 | })).rejects.toThrow('Content not found or expired'); 184 | }); 185 | }); 186 | }); 187 | 188 | describe('Error Handling', () => { 189 | const callTool = async (name: string, args: any) => { 190 | return getHandler('call_tool')({ 191 | method: 'call_tool', 192 | params: { name, arguments: args } 193 | }, {}); 194 | }; 195 | 196 | it('should handle missing tool name', async () => { 197 | await expect(getHandler('call_tool')({ 198 | method: 'call_tool', 199 | params: { arguments: {} } 200 | }, {})).rejects.toThrow('Missing tool name'); 201 | }); 202 | 203 | it('should handle missing arguments', async () => { 204 | await expect(getHandler('call_tool')({ 205 | method: 'call_tool', 206 | params: { name: 'summarize_text' } 207 | }, {})).rejects.toThrow('Missing arguments'); 208 | }); 209 | 210 | it('should handle unknown tool', async () => { 211 | await expect(callTool('unknown_tool', {})) 212 | .rejects.toThrow('Unknown tool: unknown_tool'); 213 | }); 214 | 215 | it('should handle invalid arguments', async () => { 216 | await expect(callTool('summarize_text', { invalid: 'args' })) 217 | .rejects.toThrow('Invalid arguments for summarize_text'); 218 | }); 219 | 220 | it('should handle command execution errors', async () => { 221 | await expect(callTool('summarize_command', { 222 | command: 'nonexistentcommand' 223 | })).rejects.toThrow('Error in summarize_command'); 224 | }); 225 | 226 | it('should handle file read errors', async () => { 227 | await expect(callTool('summarize_files', { 228 | paths: ['nonexistent.txt'], 229 | cwd: process.cwd() 230 | })).rejects.toThrow('Error in summarize_files'); 231 | }); 232 | 233 | it('should require cwd parameter for summarize_files', async () => { 234 | await expect(callTool('summarize_files', { 235 | paths: ['test1.txt'] 236 | })).rejects.toThrow('Invalid arguments for summarize_files'); 237 | }); 238 | 239 | it('should require cwd parameter for summarize_directory', async () => { 240 | await expect(callTool('summarize_directory', { 241 | path: '.' 242 | })).rejects.toThrow('Invalid arguments for summarize_directory'); 243 | }); 244 | 245 | it('should handle invalid cwd path', async () => { 246 | await expect(callTool('summarize_files', { 247 | paths: ['test1.txt'], 248 | cwd: '/nonexistent/directory' 249 | })).rejects.toThrow('Error in summarize_files'); 250 | 251 | await expect(callTool('summarize_directory', { 252 | path: '.', 253 | cwd: '/nonexistent/directory' 254 | })).rejects.toThrow('Error in summarize_directory'); 255 | }); 256 | }); 257 | }); -------------------------------------------------------------------------------- /src/__tests__/services/summarization.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 2 | import { SummarizationService } from '../../services/summarization.js'; 3 | import { SummarizationModel, ModelConfig } from '../../types/models.js'; 4 | 5 | class MockModel implements SummarizationModel { 6 | private initialized = false; 7 | private shouldFail: boolean; 8 | 9 | constructor(shouldFail = false) { 10 | this.shouldFail = shouldFail; 11 | } 12 | 13 | async initialize(config: ModelConfig): Promise { 14 | if (this.shouldFail) { 15 | throw new Error('Mock initialization failed'); 16 | } 17 | this.initialized = true; 18 | } 19 | 20 | async summarize(content: string, type: string): Promise { 21 | if (!this.initialized) { 22 | throw new Error('Model not initialized'); 23 | } 24 | if (this.shouldFail) { 25 | throw new Error('Mock summarization failed'); 26 | } 27 | return `Summary of ${type}: ${content.substring(0, 50)}...`; 28 | } 29 | 30 | async cleanup(): Promise { 31 | this.initialized = false; 32 | } 33 | } 34 | 35 | describe('SummarizationService', () => { 36 | let service: SummarizationService; 37 | let model: MockModel; 38 | 39 | beforeEach(() => { 40 | model = new MockModel(); 41 | service = new SummarizationService(model, { 42 | model: { apiKey: 'test-key' }, 43 | charThreshold: 100, 44 | cacheMaxAge: 1000 // 1 second for testing 45 | }); 46 | }); 47 | 48 | afterEach(async () => { 49 | await service.cleanup(); 50 | }); 51 | 52 | describe('initialization', () => { 53 | it('should initialize successfully', async () => { 54 | await expect(service.initialize()).resolves.toBeUndefined(); 55 | }); 56 | 57 | it('should handle initialization failure', async () => { 58 | const failingModel = new MockModel(true); 59 | const failingService = new SummarizationService(failingModel, { 60 | model: { apiKey: 'test-key' } 61 | }); 62 | 63 | await expect(failingService.initialize()).rejects.toThrow('Mock initialization failed'); 64 | await failingService.cleanup(); 65 | }); 66 | }); 67 | 68 | describe('content summarization', () => { 69 | beforeEach(async () => { 70 | await service.initialize(); 71 | }); 72 | 73 | it('should return original content when below threshold', async () => { 74 | const content = 'Short content'; 75 | const result = await service.maybeSummarize(content, 'text'); 76 | expect(result).toEqual({ 77 | text: content, 78 | isSummarized: false 79 | }); 80 | }); 81 | 82 | it('should summarize content when above threshold', async () => { 83 | const content = 'A'.repeat(150); // Content longer than threshold 84 | const result = await service.maybeSummarize(content, 'text'); 85 | expect(result.isSummarized).toBe(true); 86 | expect(result.id).toBeDefined(); 87 | expect(result.text).toMatch(/^Summary of text:/); 88 | }); 89 | 90 | it('should handle summarization failure', async () => { 91 | const failModel = new MockModel(); 92 | const failService = new SummarizationService(failModel, { 93 | model: { apiKey: 'test-key' }, 94 | charThreshold: 100 95 | }); 96 | 97 | await failService.initialize(); 98 | 99 | const mockSummarize = jest.spyOn(failModel, 'summarize') 100 | .mockRejectedValueOnce(new Error('Mock summarization failed')); 101 | 102 | try { 103 | const content = 'A'.repeat(150); 104 | await expect(failService.maybeSummarize(content, 'text')) 105 | .rejects.toThrow('Mock summarization failed'); 106 | } finally { 107 | mockSummarize.mockRestore(); 108 | await failService.cleanup(); 109 | } 110 | }); 111 | }); 112 | 113 | describe('content cache', () => { 114 | beforeEach(async () => { 115 | jest.useFakeTimers(); 116 | await service.initialize(); 117 | }); 118 | 119 | afterEach(async () => { 120 | jest.runOnlyPendingTimers(); 121 | jest.useRealTimers(); 122 | }); 123 | 124 | it('should store and retrieve content', async () => { 125 | const content = 'A'.repeat(150); 126 | const result = await service.maybeSummarize(content, 'text'); 127 | expect(result.id).toBeDefined(); 128 | 129 | const retrieved = service.getFullContent(result.id!); 130 | expect(retrieved).toBe(content); 131 | }); 132 | 133 | it('should return null for non-existent content', () => { 134 | const retrieved = service.getFullContent('non-existent-id'); 135 | expect(retrieved).toBeNull(); 136 | }); 137 | 138 | it('should clean up expired cache entries', async () => { 139 | const content = 'A'.repeat(150); 140 | const result = await service.maybeSummarize(content, 'text'); 141 | 142 | // Use runAllTimers to handle any pending promises 143 | await jest.runAllTimersAsync(); 144 | 145 | // Advance time past cache expiration 146 | jest.advanceTimersByTime(1100); 147 | await jest.runAllTimersAsync(); 148 | 149 | const retrieved = service.getFullContent(result.id!); 150 | expect(retrieved).toBeNull(); 151 | }); 152 | 153 | it('should throw error for invalid cache age configuration', async () => { 154 | await expect(() => new SummarizationService(model, { 155 | model: { apiKey: 'test-key' }, 156 | cacheMaxAge: -1 157 | })).toThrow('Cache max age must be a positive number'); 158 | 159 | await expect(() => new SummarizationService(model, { 160 | model: { apiKey: 'test-key' }, 161 | cacheMaxAge: 0 162 | })).toThrow('Cache max age must be a positive number'); 163 | }); 164 | }); 165 | 166 | describe('cleanup', () => { 167 | it('should clean up resources', async () => { 168 | await service.initialize(); 169 | const content = 'A'.repeat(150); 170 | const result = await service.maybeSummarize(content, 'text'); 171 | 172 | await service.cleanup(); 173 | 174 | // Cache should be cleared 175 | const retrieved = service.getFullContent(result.id!); 176 | expect(retrieved).toBeNull(); 177 | }); 178 | }); 179 | }); -------------------------------------------------------------------------------- /src/__tests__/test-files/test1.txt: -------------------------------------------------------------------------------- 1 | This is test file 1 2 | -------------------------------------------------------------------------------- /src/__tests__/test-files/test2.txt: -------------------------------------------------------------------------------- 1 | This is test file 2 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // use isomorphic-fetch polyfill: 4 | 5 | import fetch from 'isomorphic-fetch'; 6 | if (!globalThis.fetch) { 7 | globalThis.fetch = fetch; 8 | } 9 | if (global && !global.fetch) { 10 | global.fetch = fetch; 11 | } 12 | 13 | 14 | import { config } from 'dotenv'; 15 | import { createAnthropicModel } from './models/anthropic.js'; 16 | 17 | // Load environment variables from .env file if present 18 | if (process.env.NODE_ENV !== 'production') { 19 | config(); 20 | } 21 | import { SummarizationService } from './services/summarization.js'; 22 | import { McpServer } from './server/mcp-server.js'; 23 | import { SummarizationConfig } from './types/models.js'; 24 | import { initializeModel } from './models/index.js'; 25 | 26 | 27 | async function main() { 28 | // Model configuration 29 | const provider = process.env.PROVIDER; 30 | if (!provider) { 31 | throw new Error('PROVIDER environment variable is required'); 32 | } 33 | const apiKey = process.env.API_KEY; 34 | if (!apiKey) { 35 | throw new Error('API_KEY environment variable is required'); 36 | } 37 | const modelId = process.env.MODEL_ID; 38 | 39 | let baseUrl = null; 40 | if (process.env.PROVIDER_BASE_URL) { 41 | baseUrl = process.env.PROVIDER_BASE_URL; 42 | } 43 | 44 | 45 | // 46 | let maxTokens = 1024; 47 | if (process.env.MAX_TOKENS) { 48 | maxTokens = parseInt(process.env.MAX_TOKENS) || 1024; 49 | } 50 | let charThreshold = 512; 51 | if (process.env.SUMMARIZATION_CHAR_THRESHOLD) { 52 | charThreshold = parseInt(process.env.SUMMARIZATION_CHAR_THRESHOLD) || 512; 53 | } 54 | let cacheMaxAge = 1000 * 60 * 60; 55 | if (process.env.SUMMARIZATION_CACHE_MAX_AGE) { 56 | cacheMaxAge = parseInt(process.env.SUMMARIZATION_CACHE_MAX_AGE) || 1000 * 60 * 60; 57 | } 58 | 59 | 60 | 61 | try { 62 | // Create the configuration 63 | const config: SummarizationConfig = { 64 | model: { 65 | apiKey: apiKey, 66 | model: modelId, 67 | maxTokens: maxTokens, 68 | baseUrl: null 69 | }, 70 | charThreshold: charThreshold, // Threshold for when to summarize 71 | cacheMaxAge: cacheMaxAge 72 | }; 73 | 74 | const model = initializeModel(provider, config.model); 75 | 76 | // Create and initialize the summarization service 77 | const summarizationService = new SummarizationService(model, config); 78 | await summarizationService.initialize(); 79 | 80 | // Create and start the MCP server 81 | const server = new McpServer(summarizationService); 82 | await server.start(); 83 | 84 | // Handle cleanup on process termination 85 | process.on('SIGINT', async () => { 86 | await server.cleanup(); 87 | process.exit(0); 88 | }); 89 | 90 | process.on('SIGTERM', async () => { 91 | await server.cleanup(); 92 | process.exit(0); 93 | }); 94 | } catch (error) { 95 | console.error('Failed to start server:', error); 96 | process.exit(1); 97 | } 98 | } 99 | 100 | main().catch(console.error); 101 | -------------------------------------------------------------------------------- /src/models/anthropic.ts: -------------------------------------------------------------------------------- 1 | import { ModelConfig, SummarizationModel, SummarizationOptions } from '../types/models.js'; 2 | import { constructPrompt } from './prompts.js'; 3 | 4 | import { Anthropic, APIError, ClientOptions } from '@anthropic-ai/sdk'; 5 | import { Message } from '@anthropic-ai/sdk/resources/messages'; 6 | 7 | export class AnthropicModel implements SummarizationModel { 8 | private config: ModelConfig | null = null; 9 | private baseUrl = 'https://api.anthropic.com/v1/messages'; 10 | private anthropic: Anthropic | undefined; 11 | clientOptions: ClientOptions; 12 | 13 | constructor(clientOptions: ClientOptions = {}) { 14 | this.clientOptions = clientOptions; 15 | } 16 | 17 | 18 | async initialize(config: ModelConfig): Promise { 19 | if (!config.apiKey) { 20 | throw new Error('API key is required for Anthropic model'); 21 | } 22 | 23 | const model = config.model || 'claude-3-5-haiku-20241022'; 24 | const maxTokens = config.maxTokens !== undefined ? config.maxTokens : 1024; 25 | 26 | // Validate model name 27 | if (typeof model !== 'string' || !model.trim()) { 28 | throw new Error('Invalid model name'); 29 | } 30 | 31 | // Validate maxTokens - explicitly check for 0 and negative values 32 | if (!Number.isInteger(maxTokens) || maxTokens <= 0) { 33 | throw new Error('Invalid max tokens value'); 34 | } 35 | 36 | process.env.ANTHROPIC_API_KEY = config.apiKey; 37 | 38 | 39 | this.anthropic = new Anthropic({ 40 | apiKey: config.apiKey, 41 | ...this.clientOptions 42 | }); 43 | 44 | 45 | this.config = { 46 | model, 47 | maxTokens, 48 | apiKey: config.apiKey 49 | }; 50 | } 51 | 52 | async summarize(content: string, type: string, options?: SummarizationOptions): Promise { 53 | if (!this.config) { 54 | throw new Error('Anthropic model not initialized'); 55 | } 56 | if (!this.anthropic) { 57 | throw new Error('Anthropic SDK not initialized'); 58 | } 59 | 60 | const result = constructPrompt('anthropic', content, type, options); 61 | if (result.format !== 'anthropic') { 62 | throw new Error('Unexpected prompt format returned'); 63 | } 64 | const prompt = result.prompt; 65 | 66 | try { 67 | let data: Message; 68 | try { 69 | data = await this.anthropic.messages.create({ 70 | model: this.config.model!!, 71 | max_tokens: this.config.maxTokens!!, 72 | messages: [{ 73 | role: 'user', 74 | content: prompt 75 | }] 76 | }); 77 | } catch (fetchError: any) { 78 | if (fetchError instanceof APIError) { 79 | const error = fetchError as APIError; 80 | throw new Error(`API error: ${error.message}`); 81 | } else { 82 | throw new Error(`Network error: ${fetchError.message}`); 83 | } 84 | } 85 | 86 | const msg = data.content[0] as { text: string, type: string }; 87 | 88 | if (!Array.isArray(data.content) || !msg.text || msg.type !== 'text') { 89 | throw new Error('Unexpected response format from Anthropic API'); 90 | } 91 | 92 | return msg.text; 93 | } catch (error) { 94 | if (error instanceof Error) { 95 | throw new Error(`Anthropic summarization failed: ${error.message}`); 96 | } 97 | throw new Error('Anthropic summarization failed: Unknown error'); 98 | } 99 | } 100 | 101 | async cleanup(): Promise { 102 | this.config = null; 103 | } 104 | } 105 | 106 | // Factory function to create a new Anthropic model instance 107 | export function createAnthropicModel(options: ClientOptions = {}): SummarizationModel { 108 | return new AnthropicModel(options); 109 | } -------------------------------------------------------------------------------- /src/models/gemini.ts: -------------------------------------------------------------------------------- 1 | import { ModelConfig, SummarizationModel, SummarizationOptions } from '../types/models.js'; 2 | import { constructPrompt } from './prompts.js'; 3 | 4 | interface GeminiResponse { 5 | candidates: Array<{ 6 | content: { 7 | parts: Array<{ 8 | text: string; 9 | }>; 10 | }; 11 | }>; 12 | } 13 | 14 | export class GeminiModel implements SummarizationModel { 15 | private config: ModelConfig | null = null; 16 | private baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models'; 17 | 18 | async initialize(config: ModelConfig): Promise { 19 | if (!config.apiKey) { 20 | throw new Error('API key is required for Gemini model'); 21 | } 22 | 23 | const model = config.model || 'gemini-1.5-flash'; 24 | const maxTokens = config.maxTokens !== undefined ? config.maxTokens : 1024; 25 | 26 | // Validate model name 27 | if (typeof model !== 'string' || !model.trim()) { 28 | throw new Error('Invalid model name'); 29 | } 30 | 31 | // Validate max tokens 32 | if (typeof maxTokens !== 'number' || maxTokens <= 0) { 33 | throw new Error('Invalid max tokens value'); 34 | } 35 | 36 | this.config = { 37 | ...config, 38 | model, 39 | maxTokens, 40 | }; 41 | } 42 | 43 | async summarize(content: string, type: string, options?: SummarizationOptions): Promise { 44 | if (!this.config) { 45 | throw new Error('Model not initialized'); 46 | } 47 | 48 | const result = constructPrompt('gemini', content, type, options); 49 | if (result.format !== 'gemini') { 50 | throw new Error('Unexpected prompt format returned'); 51 | } 52 | 53 | const { apiKey, model, maxTokens } = this.config; 54 | 55 | const response = await fetch(`${this.baseUrl}/${model}:generateContent?key=${apiKey}`, { 56 | method: 'POST', 57 | headers: { 58 | 'Content-Type': 'application/json', 59 | }, 60 | body: JSON.stringify({ 61 | contents: result.messages, 62 | generationConfig: { 63 | maxOutputTokens: maxTokens, 64 | }, 65 | }), 66 | }); 67 | 68 | if (!response.ok) { 69 | throw new Error(`Failed to fetch summary: ${response.statusText}`); 70 | } 71 | 72 | const data: GeminiResponse = await response.json() as GeminiResponse; 73 | return data.candidates[0].content.parts[0].text; 74 | } 75 | 76 | async cleanup(): Promise { 77 | // No cleanup needed for Gemini model 78 | } 79 | } -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | import { ModelConfig, SummarizationModel } from "../types/models.js"; 2 | import { AnthropicModel } from "./anthropic.js"; 3 | import { OpenAIModel } from "./openai.js"; 4 | import { OpenAICompatible } from "./openai-compatible.js"; 5 | import { GeminiModel } from "./gemini.js"; 6 | 7 | export function initializeModel(provider: String, config: ModelConfig): SummarizationModel { 8 | let model: SummarizationModel; 9 | switch (provider) { 10 | case 'ANTHROPIC': 11 | model = new AnthropicModel(); 12 | break; 13 | case 'OPENAI': 14 | model = new OpenAIModel(); 15 | break; 16 | case 'OPENAI-COMPATIBLE': 17 | model = new OpenAICompatible(); 18 | break; 19 | case 'GOOGLE': 20 | model = new GeminiModel(); 21 | break; 22 | default: 23 | throw new Error(`Unsupported provider: ${provider}`); 24 | } 25 | model.initialize(config); 26 | 27 | return model; 28 | } -------------------------------------------------------------------------------- /src/models/openai-compatible.ts: -------------------------------------------------------------------------------- 1 | import { ModelConfig, SummarizationModel, SummarizationOptions } from '../types/models.js'; 2 | import { constructPrompt } from './prompts.js'; 3 | 4 | export class OpenAICompatible implements SummarizationModel { 5 | protected config: ModelConfig | null = null; 6 | protected baseUrl = 'https://api.openai.com/v1'; 7 | 8 | async initialize(config: ModelConfig): Promise { 9 | if (!config.apiKey) { 10 | throw new Error('API key is required for OpenAI compatible models'); 11 | } 12 | 13 | const model = config.model || 'gpt-4o-mini'; 14 | const maxTokens = config.maxTokens !== undefined ? config.maxTokens : 1024; 15 | 16 | // Validate model name 17 | if (typeof model !== 'string' || !model.trim()) { 18 | throw new Error('Invalid model name'); 19 | } 20 | 21 | // Validate max tokens 22 | if (typeof maxTokens !== 'number' || maxTokens <= 0) { 23 | throw new Error('Invalid max tokens value'); 24 | } 25 | 26 | this.config = { 27 | ...config, 28 | model, 29 | maxTokens, 30 | }; 31 | } 32 | 33 | async summarize(content: string, type: string, options?: SummarizationOptions): Promise { 34 | if (!this.config) { 35 | throw new Error('Model not initialized'); 36 | } 37 | 38 | const result = constructPrompt('openai', content, type, options); 39 | if (result.format !== 'openai') { 40 | throw new Error('Unexpected prompt format returned'); 41 | } 42 | 43 | const { apiKey, model, maxTokens } = this.config; 44 | 45 | const response = await fetch(`${this.baseUrl}/chat/completions`, { 46 | method: 'POST', 47 | headers: { 48 | 'Content-Type': 'application/json', 49 | 'Authorization': `Bearer ${apiKey}`, 50 | }, 51 | body: JSON.stringify({ 52 | model, 53 | messages: result.messages, 54 | max_tokens: maxTokens, 55 | }), 56 | }); 57 | 58 | if (!response.ok) { 59 | throw new Error(`API request failed with status ${response.status}`); 60 | } 61 | 62 | const data = await response.json() as { choices: Array<{ message: { content: string } }> }; 63 | if (data.choices && data.choices.length > 0) { 64 | return data.choices[0].message.content; 65 | } else { 66 | throw new Error('No summary was returned from the API'); 67 | } 68 | } 69 | 70 | async cleanup(): Promise { 71 | // No cleanup needed for OpenAI compatible models 72 | } 73 | } -------------------------------------------------------------------------------- /src/models/openai.ts: -------------------------------------------------------------------------------- 1 | import { OpenAICompatible } from './openai-compatible.js'; 2 | import { SummarizationModel, ModelConfig } from '../types/models.js'; 3 | 4 | export class OpenAIModel extends OpenAICompatible { 5 | constructor() { 6 | super(); 7 | } 8 | 9 | async summarize(content: string, type: string): Promise { 10 | return super.summarize(content, type); 11 | } 12 | } -------------------------------------------------------------------------------- /src/models/prompts.ts: -------------------------------------------------------------------------------- 1 | import { SummarizationOptions } from '../types/models.js'; 2 | 3 | /** 4 | * Formats for different model providers 5 | */ 6 | export type PromptFormat = 'anthropic' | 'openai' | 'gemini'; 7 | 8 | /** 9 | * Base interface for all prompt types 10 | */ 11 | interface BasePrompt { 12 | format: PromptFormat; 13 | } 14 | 15 | /** 16 | * Anthropic-style single string prompt 17 | */ 18 | interface AnthropicPrompt extends BasePrompt { 19 | format: 'anthropic'; 20 | prompt: string; 21 | } 22 | 23 | /** 24 | * OpenAI-style chat messages 25 | */ 26 | interface OpenAIPrompt extends BasePrompt { 27 | format: 'openai'; 28 | messages: Array<{ 29 | role: 'system' | 'user' | 'assistant'; 30 | content: string; 31 | }>; 32 | } 33 | 34 | /** 35 | * Gemini-style chat messages 36 | */ 37 | interface GeminiPrompt extends BasePrompt { 38 | format: 'gemini'; 39 | messages: Array<{ 40 | role: 'user' | 'model'; 41 | parts: Array<{ 42 | text: string; 43 | }>; 44 | }>; 45 | } 46 | 47 | export type ModelPrompt = AnthropicPrompt | OpenAIPrompt | GeminiPrompt; 48 | 49 | /** 50 | * Constructs hint-specific instructions based on the hint type 51 | */ 52 | function getHintInstructions(hint?: string): string { 53 | if (!hint) return ''; 54 | 55 | const hintInstructions: Record = { 56 | security_analysis: 'Focus on security-critical aspects including authentication, authorization, crypto operations, data validation, and error handling patterns.', 57 | api_surface: 'Focus on public API interfaces, documenting parameters/return types, noting deprecation warnings and potential breaking changes.', 58 | error_handling: 'Focus on error handling patterns, exception flows, and recovery mechanisms.', 59 | dependencies: 'Focus on import/export relationships, external dependencies, and potential circular dependencies.', 60 | type_definitions: 'Focus on type structures, relationships, and hierarchies.' 61 | }; 62 | 63 | if (!hintInstructions[hint]) { 64 | // custom hint instructions 65 | return `Agent provided a hint for analysis: ${hint}. Please focus on this area in your summary.`; 66 | } 67 | 68 | return hintInstructions[hint] || ''; 69 | } 70 | 71 | /** 72 | * Constructs format-specific instructions based on the output format 73 | */ 74 | function getFormatInstructions(output_format?: string): string { 75 | if (!output_format || output_format === 'text') return ''; 76 | 77 | const formatInstructions: Record = { 78 | json: 'Provide the summary in JSON format with a "summary" field and a "metadata" object containing focus_areas, key_components, and relationships.', 79 | markdown: 'Format the summary in Markdown with clear sections, a table of contents, and preserved code blocks where relevant.', 80 | outline: 'Present the summary as a hierarchical outline with relationship indicators and importance markers.' 81 | }; 82 | 83 | return formatInstructions[output_format] || ''; 84 | } 85 | 86 | /** 87 | * Constructs the base summarization instructions 88 | */ 89 | export function getBaseSummarizationInstructions(type: string): string { 90 | return `Summarize the following ${type} in a clear, concise way that would be useful for an AI agent. Focus on the most important information and maintain technical accuracy. Always keep your summary shorter than 4096 tokens.`; 91 | } 92 | 93 | export function getFinalInstructions(): string[] { 94 | return ["Please do not include any commentary, questions or text other than the relevant summary itself."]; 95 | } 96 | 97 | /** 98 | * Combines all instructions into a complete prompt 99 | */ 100 | function constructFullInstructions(type: string, options?: SummarizationOptions): string { 101 | const baseInstructions = getBaseSummarizationInstructions(type); 102 | const hintInstructions = getHintInstructions(options?.hint); 103 | const formatInstructions = getFormatInstructions(options?.output_format); 104 | const finalInstructions = getFinalInstructions(); 105 | 106 | return [ 107 | baseInstructions, 108 | hintInstructions, 109 | formatInstructions, 110 | ...finalInstructions 111 | ].filter(Boolean).join('\n\n'); 112 | } 113 | 114 | /** 115 | * Creates an Anthropic-style prompt 116 | */ 117 | function createAnthropicPrompt(content: string, type: string, options?: SummarizationOptions): AnthropicPrompt { 118 | const instructions = constructFullInstructions(type, options); 119 | return { 120 | format: 'anthropic', 121 | prompt: `${instructions}\n\n${content}\n\nSummary:`, 122 | }; 123 | } 124 | 125 | /** 126 | * Creates an OpenAI-style prompt 127 | */ 128 | function createOpenAIPrompt(content: string, type: string, options?: SummarizationOptions): OpenAIPrompt { 129 | const instructions = constructFullInstructions(type, options); 130 | return { 131 | format: 'openai', 132 | messages: [ 133 | { 134 | role: 'system', 135 | content: `You are a helpful assistant that summarizes ${type} content.${ 136 | options?.hint ? ` You specialize in ${options.hint} analysis.` : '' 137 | }`, 138 | }, 139 | { 140 | role: 'user', 141 | content: `${instructions}\n\n${content}`, 142 | }, 143 | ], 144 | }; 145 | } 146 | 147 | /** 148 | * Creates a Gemini-style prompt 149 | */ 150 | function createGeminiPrompt(content: string, type: string, options?: SummarizationOptions): GeminiPrompt { 151 | const instructions = constructFullInstructions(type, options); 152 | return { 153 | format: 'gemini', 154 | messages: [ 155 | { 156 | role: 'user', 157 | parts: [{ 158 | text: `${instructions}\n\n${content}`, 159 | }], 160 | }, 161 | ], 162 | }; 163 | } 164 | 165 | /** 166 | * Main function to construct prompts for any model type 167 | */ 168 | export function constructPrompt( 169 | format: PromptFormat, 170 | content: string, 171 | type: string, 172 | options?: SummarizationOptions 173 | ): ModelPrompt { 174 | switch (format) { 175 | case 'anthropic': 176 | return createAnthropicPrompt(content, type, options); 177 | case 'openai': 178 | return createOpenAIPrompt(content, type, options); 179 | case 'gemini': 180 | return createGeminiPrompt(content, type, options); 181 | default: 182 | throw new Error(`Unsupported prompt format: ${format}`); 183 | } 184 | } -------------------------------------------------------------------------------- /src/server/mcp-server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 3 | import { 4 | CallToolRequestSchema, 5 | ErrorCode, 6 | ListToolsRequestSchema, 7 | McpError, 8 | } from '@modelcontextprotocol/sdk/types.js'; 9 | import { execa } from 'execa'; 10 | import * as fs from 'fs/promises'; 11 | import * as path from 'path'; 12 | import { SummarizationService } from '../services/summarization.js'; 13 | 14 | // Type definitions for tool arguments 15 | interface SummarizeCommandArgs { 16 | command: string; 17 | cwd?: string; 18 | hint?: string; 19 | output_format?: string; 20 | } 21 | 22 | interface SummarizeFilesArgs { 23 | paths: string[]; 24 | cwd: string; 25 | hint?: string; 26 | output_format?: string; 27 | } 28 | 29 | interface SummarizeDirectoryArgs { 30 | path: string; 31 | cwd: string; 32 | recursive?: boolean; 33 | hint?: string; 34 | output_format?: string; 35 | } 36 | 37 | interface SummarizeTextArgs { 38 | content: string; 39 | type: string; 40 | hint?: string; 41 | output_format?: string; 42 | } 43 | 44 | interface GetFullContentArgs { 45 | id: string; 46 | } 47 | 48 | // Type guards 49 | function isValidFormatParams(args: any): boolean { 50 | if ('hint' in args && typeof args.hint !== 'string') return false; 51 | if ('output_format' in args && typeof args.output_format !== 'string') return false; 52 | return true; 53 | } 54 | 55 | function isSummarizeCommandArgs(args: unknown): args is SummarizeCommandArgs { 56 | return typeof args === 'object' && args !== null && 57 | 'command' in args && typeof (args as any).command === 'string' && 58 | isValidFormatParams(args); 59 | } 60 | 61 | function isSummarizeFilesArgs(args: unknown): args is SummarizeFilesArgs { 62 | return typeof args === 'object' && args !== null && 63 | 'paths' in args && Array.isArray((args as any).paths) && 64 | 'cwd' in args && typeof (args as any).cwd === 'string' && 65 | isValidFormatParams(args); 66 | } 67 | 68 | function isSummarizeDirectoryArgs(args: unknown): args is SummarizeDirectoryArgs { 69 | return typeof args === 'object' && args !== null && 70 | 'path' in args && typeof (args as any).path === 'string' && 71 | 'cwd' in args && typeof (args as any).cwd === 'string' && 72 | (!('recursive' in args) || typeof (args as any).recursive === 'boolean') && 73 | isValidFormatParams(args); 74 | } 75 | 76 | function isSummarizeTextArgs(args: unknown): args is SummarizeTextArgs { 77 | return typeof args === 'object' && args !== null && 78 | 'content' in args && typeof (args as any).content === 'string' && 79 | 'type' in args && typeof (args as any).type === 'string' && 80 | isValidFormatParams(args); 81 | } 82 | 83 | function isGetFullContentArgs(args: unknown): args is GetFullContentArgs { 84 | return typeof args === 'object' && args !== null && 'id' in args && typeof (args as any).id === 'string'; 85 | } 86 | 87 | const directioriesIgnore = [ 88 | "node_modules", 89 | "build", 90 | "dist", 91 | "coverage", 92 | ".git", 93 | ".idea", 94 | ".vscode", 95 | "out", 96 | "public", 97 | "tmp", 98 | "temp", 99 | "vendor", 100 | "logs" 101 | ] 102 | 103 | export class McpServer { 104 | private server: Server; 105 | private summarizationService: SummarizationService; 106 | private workingDirectory: string; 107 | 108 | constructor(summarizationService: SummarizationService) { 109 | // Get working directory from MCP_WORKING_DIR environment variable 110 | this.workingDirectory = process.env.MCP_WORKING_DIR || '/'; 111 | if (this.workingDirectory === '/') { 112 | console.error('Warning: MCP_WORKING_DIR not set, using root directory'); 113 | } 114 | console.error('Working directory:', this.workingDirectory); 115 | this.summarizationService = summarizationService; 116 | 117 | this.server = new Server( 118 | { 119 | name: 'summarization-functions', 120 | version: '0.1.0', 121 | }, 122 | { 123 | capabilities: { 124 | tools: {}, 125 | }, 126 | } 127 | ); 128 | 129 | this.setupToolHandlers(); 130 | 131 | // Error handling 132 | this.server.onerror = (error: Error) => console.error('[MCP Error]', error); 133 | process.on('SIGINT', async () => { 134 | await this.cleanup(); 135 | process.exit(0); 136 | }); 137 | } 138 | 139 | private setupToolHandlers(): void { 140 | const formatParameters = { 141 | hint: { 142 | type: 'string', 143 | description: 'Focus area for summarization (e.g., "security_analysis", "api_surface", "error_handling", "dependencies", "type_definitions")', 144 | enum: ['security_analysis', 'api_surface', 'error_handling', 'dependencies', 'type_definitions'] 145 | }, 146 | output_format: { 147 | type: 'string', 148 | description: 'Desired output format (e.g., "text", "json", "markdown", "outline")', 149 | enum: ['text', 'json', 'markdown', 'outline'], 150 | default: 'text' 151 | } 152 | }; 153 | 154 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ 155 | tools: [ 156 | { 157 | name: 'summarize_command', 158 | description: 'Execute a command and summarize its output if it exceeds the threshold', 159 | inputSchema: { 160 | type: 'object', 161 | properties: { 162 | command: { 163 | type: 'string', 164 | description: 'Command to execute', 165 | }, 166 | cwd: { 167 | type: 'string', 168 | description: 'Working directory for command execution', 169 | }, 170 | ...formatParameters 171 | }, 172 | required: ['command', 'cwd'], 173 | }, 174 | }, 175 | { 176 | name: 'summarize_files', 177 | description: 'Summarize the contents of one or more files', 178 | inputSchema: { 179 | type: 'object', 180 | properties: { 181 | paths: { 182 | type: 'array', 183 | items: { 184 | type: 'string', 185 | }, 186 | description: 'Array of file paths to summarize (relative to cwd)', 187 | }, 188 | cwd: { 189 | type: 'string', 190 | description: 'Working directory for resolving file paths', 191 | }, 192 | ...formatParameters 193 | }, 194 | required: ['paths', 'cwd'], 195 | }, 196 | }, 197 | { 198 | name: 'summarize_directory', 199 | description: 'Summarize the structure of a directory', 200 | inputSchema: { 201 | type: 'object', 202 | properties: { 203 | path: { 204 | type: 'string', 205 | description: 'Directory path to summarize (relative to cwd)', 206 | }, 207 | cwd: { 208 | type: 'string', 209 | description: 'Working directory for resolving directory path', 210 | }, 211 | recursive: { 212 | type: 'boolean', 213 | description: 'Whether to include subdirectories, safe for large directories', 214 | }, 215 | ...formatParameters 216 | }, 217 | required: ['path', 'cwd'], 218 | }, 219 | }, 220 | { 221 | name: 'summarize_text', 222 | description: 'Summarize any text content (e.g., MCP tool output)', 223 | inputSchema: { 224 | type: 'object', 225 | properties: { 226 | content: { 227 | type: 'string', 228 | description: 'Text content to summarize', 229 | }, 230 | type: { 231 | type: 'string', 232 | description: 'Type of content (e.g., "log output", "API response")', 233 | }, 234 | ...formatParameters 235 | }, 236 | required: ['content', 'type'], 237 | }, 238 | }, 239 | { 240 | name: 'get_full_content', 241 | description: 'Retrieve the full content for a given summary ID', 242 | inputSchema: { 243 | type: 'object', 244 | properties: { 245 | id: { 246 | type: 'string', 247 | description: 'ID of the stored content', 248 | }, 249 | }, 250 | required: ['id'], 251 | }, 252 | }, 253 | ], 254 | })); 255 | 256 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 257 | if (!request.params?.name) { 258 | throw new McpError( 259 | ErrorCode.InvalidRequest, 260 | 'Missing tool name' 261 | ); 262 | } 263 | 264 | if (!request.params.arguments) { 265 | throw new McpError( 266 | ErrorCode.InvalidRequest, 267 | 'Missing arguments' 268 | ); 269 | } 270 | 271 | try { 272 | const response = await (async () => { 273 | switch (request.params.name) { 274 | case 'summarize_command': 275 | if (!isSummarizeCommandArgs(request.params.arguments)) { 276 | throw new McpError(ErrorCode.InvalidRequest, 'Invalid arguments for summarize_command'); 277 | } 278 | return await this.handleSummarizeCommand(request.params.arguments); 279 | case 'summarize_files': 280 | if (!isSummarizeFilesArgs(request.params.arguments)) { 281 | throw new McpError(ErrorCode.InvalidRequest, 'Invalid arguments for summarize_files'); 282 | } 283 | return await this.handleSummarizeFiles(request.params.arguments); 284 | case 'summarize_directory': 285 | if (!isSummarizeDirectoryArgs(request.params.arguments)) { 286 | throw new McpError(ErrorCode.InvalidRequest, 'Invalid arguments for summarize_directory'); 287 | } 288 | return await this.handleSummarizeDirectory(request.params.arguments); 289 | case 'summarize_text': 290 | if (!isSummarizeTextArgs(request.params.arguments)) { 291 | throw new McpError(ErrorCode.InvalidRequest, 'Invalid arguments for summarize_text'); 292 | } 293 | return await this.handleSummarizeText(request.params.arguments); 294 | case 'get_full_content': 295 | if (!isGetFullContentArgs(request.params.arguments)) { 296 | throw new McpError(ErrorCode.InvalidRequest, 'Invalid arguments for get_full_content'); 297 | } 298 | return await this.handleGetFullContent(request.params.arguments); 299 | default: 300 | throw new McpError( 301 | ErrorCode.MethodNotFound, 302 | `Unknown tool: ${request.params.name}` 303 | ); 304 | } 305 | })(); 306 | 307 | return { 308 | _meta: {}, 309 | ...response, 310 | }; 311 | } catch (error) { 312 | if (error instanceof McpError) throw error; 313 | throw new McpError( 314 | ErrorCode.InternalError, 315 | `Error in ${request.params.name}: ${(error as Error).message}` 316 | ); 317 | } 318 | }); 319 | } 320 | 321 | private async handleSummarizeCommand(args: SummarizeCommandArgs) { 322 | const { stdout, stderr } = await execa(args.command, { 323 | shell: true, 324 | cwd: args.cwd, 325 | }); 326 | 327 | const output = stdout + (stderr ? `\nError: ${stderr}` : ''); 328 | const result = await this.summarizationService.maybeSummarize(output, 'command output', { 329 | hint: args.hint, 330 | output_format: args.output_format 331 | }); 332 | 333 | return { 334 | content: [ 335 | { 336 | type: 'text', 337 | text: result.isSummarized 338 | ? `Summary (full content ID: ${result.id}):\n${result.text}` 339 | : result.text, 340 | }, 341 | ], 342 | }; 343 | } 344 | 345 | private async handleSummarizeFiles(args: SummarizeFilesArgs) { 346 | try { 347 | const results = await Promise.all( 348 | args.paths.map(async (filePath: string) => { 349 | try { 350 | const resolvedPath = await this.parsePath(filePath, args.cwd || this.workingDirectory, 'summarize_files'); 351 | const content = await fs.readFile(resolvedPath, 'utf-8'); 352 | const result = await this.summarizationService.maybeSummarize( 353 | content, 354 | `code from ${path.basename(filePath)}`, 355 | { 356 | hint: args.hint, 357 | output_format: args.output_format 358 | } 359 | ); 360 | return { path: filePath, ...result }; 361 | } catch (error) { 362 | throw new McpError( 363 | ErrorCode.InternalError, 364 | `Error in summarize_files: ${(error as Error).message}` 365 | ); 366 | } 367 | }) 368 | ); 369 | 370 | return { 371 | content: [ 372 | { 373 | type: 'text', 374 | text: results 375 | .map( 376 | (result) => 377 | `${result.path}:\n${ 378 | result.isSummarized 379 | ? `Summary (full content ID: ${result.id}):\n${result.text}` 380 | : result.text 381 | }\n` 382 | ) 383 | .join('\n'), 384 | }, 385 | ], 386 | }; 387 | } catch (error) { 388 | if (error instanceof McpError) throw error; 389 | throw new McpError( 390 | ErrorCode.InternalError, 391 | `Error in summarize_files: ${(error as Error).message}` 392 | ); 393 | } 394 | } 395 | 396 | private async parsePath(filePath: string, cwd: string, toolName: string): Promise { 397 | // Always treat paths as relative to the provided working directory 398 | const resolvedPath = path.join(cwd, filePath); 399 | 400 | try { 401 | // Check if path exists and is accessible 402 | const stats = await fs.stat(resolvedPath); 403 | return resolvedPath; 404 | } catch (error) { 405 | throw new McpError( 406 | ErrorCode.InternalError, 407 | `Error in ${toolName}: Path not found: ${filePath} (resolved to ${resolvedPath})` 408 | ); 409 | } 410 | } 411 | 412 | private async handleSummarizeDirectory(args: SummarizeDirectoryArgs) { 413 | try { 414 | const MAX_DEPTH = 5; // Maximum directory depth 415 | const MAX_FILES = 1000; // Maximum number of files to process 416 | const MAX_FILES_PER_DIR = 100; // Maximum files to show per directory 417 | 418 | const resolvedPath = await this.parsePath(args.path, args.cwd || this.workingDirectory, 'summarize_directory'); 419 | let totalFiles = 0; 420 | let truncated = false; 421 | 422 | const listDir = async (dir: string, recursive: boolean, depth: number = 0): Promise => { 423 | try { 424 | if (depth >= MAX_DEPTH) { 425 | return `[Directory depth limit (${MAX_DEPTH}) reached]\n`; 426 | } 427 | 428 | const items = await fs.readdir(dir, { withFileTypes: true }); 429 | let output = ''; 430 | let fileCount = 0; 431 | 432 | // Sort items to show directories first 433 | const sortedItems = items.sort((a, b) => { 434 | if (a.isDirectory() && !b.isDirectory()) return -1; 435 | if (!a.isDirectory() && b.isDirectory()) return 1; 436 | return a.name.localeCompare(b.name); 437 | }); 438 | 439 | for (const item of sortedItems) { 440 | if (totalFiles >= MAX_FILES) { 441 | truncated = true; 442 | break; 443 | } 444 | 445 | const fullPath = path.join(dir, item.name); 446 | const relativePath = path.relative(resolvedPath, fullPath); 447 | 448 | if (item.isDirectory()) { 449 | output += `${relativePath}/\n`; 450 | if (directioriesIgnore.includes(item.name)) { 451 | output += `[${item.name}/ contents skipped]\n`; 452 | continue; 453 | } 454 | if (recursive) { 455 | output += await listDir(fullPath, recursive, depth + 1); 456 | } 457 | } else { 458 | if (fileCount >= MAX_FILES_PER_DIR) { 459 | if (fileCount === MAX_FILES_PER_DIR) { 460 | output += `[${items.length - fileCount} more files in this directory]\n`; 461 | } 462 | continue; 463 | } 464 | output += `${relativePath}\n`; 465 | fileCount++; 466 | totalFiles++; 467 | } 468 | } 469 | 470 | return output; 471 | } catch (error) { 472 | throw new McpError( 473 | ErrorCode.InternalError, 474 | `Error in summarize_directory: ${(error as Error).message}` 475 | ); 476 | } 477 | }; 478 | 479 | let listing = await listDir(resolvedPath, args.recursive ?? false); 480 | if (truncated) { 481 | listing += `\n[Output truncated: Reached maximum file limit of ${MAX_FILES}]\n`; 482 | } 483 | 484 | // Always force summarization for directory listings 485 | const result = await this.summarizationService.maybeSummarize( 486 | listing, 487 | 'directory listing', 488 | { 489 | hint: args.hint, 490 | output_format: args.output_format 491 | } 492 | ); 493 | 494 | return { 495 | content: [ 496 | { 497 | type: 'text', 498 | text: `Summary (full content ID: ${result.id}):\n${result.text}`, 499 | }, 500 | ], 501 | }; 502 | } catch (error) { 503 | if (error instanceof McpError) throw error; 504 | throw new McpError( 505 | ErrorCode.InternalError, 506 | `Error in summarize_directory: ${(error as Error).message}` 507 | ); 508 | } 509 | } 510 | 511 | private async handleSummarizeText(args: SummarizeTextArgs) { 512 | const result = await this.summarizationService.maybeSummarize(args.content, args.type, { 513 | hint: args.hint, 514 | output_format: args.output_format 515 | }); 516 | 517 | return { 518 | content: [ 519 | { 520 | type: 'text', 521 | text: result.isSummarized 522 | ? `Summary (full content ID: ${result.id}):\n${result.text}` 523 | : result.text, 524 | } 525 | ], 526 | }; 527 | } 528 | 529 | private async handleGetFullContent(args: GetFullContentArgs) { 530 | const content = this.summarizationService.getFullContent(args.id); 531 | if (!content) { 532 | throw new McpError( 533 | ErrorCode.InvalidRequest, 534 | 'Content not found or expired' 535 | ); 536 | } 537 | 538 | return { 539 | content: [ 540 | { 541 | type: 'text', 542 | text: content, 543 | }, 544 | ], 545 | }; 546 | } 547 | 548 | async start(): Promise { 549 | const transport = new StdioServerTransport(); 550 | await this.server.connect(transport); 551 | console.error('Summarization MCP server running on stdio'); 552 | } 553 | 554 | async cleanup(): Promise { 555 | await this.summarizationService.cleanup(); 556 | await this.server.close(); 557 | } 558 | } -------------------------------------------------------------------------------- /src/services/summarization.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { SummarizationModel, SummarizationConfig } from '../types/models.js'; 3 | 4 | interface CacheEntry { 5 | content: string; 6 | timestamp: number; 7 | } 8 | 9 | export class SummarizationService { 10 | private model: SummarizationModel; 11 | private config: SummarizationConfig; 12 | private contentCache: Map; 13 | private charThreshold: number; 14 | private cacheMaxAge: number; 15 | private cleanupInterval: NodeJS.Timeout; 16 | 17 | constructor( 18 | model: SummarizationModel, 19 | config: SummarizationConfig 20 | ) { 21 | this.model = model; 22 | this.config = config; 23 | this.contentCache = new Map(); 24 | this.charThreshold = config.charThreshold || 512; 25 | 26 | // Validate cache max age 27 | if (config.cacheMaxAge !== undefined && config.cacheMaxAge <= 0) { 28 | throw new Error('Cache max age must be a positive number'); 29 | } 30 | this.cacheMaxAge = config.cacheMaxAge || 1000 * 60 * 60; // 1 hour default 31 | 32 | // Start periodic cache cleanup 33 | this.cleanupInterval = setInterval(() => this.cleanupCache(), this.cacheMaxAge); 34 | } 35 | 36 | /** 37 | * Initialize the service and its model 38 | */ 39 | async initialize(): Promise { 40 | await this.model.initialize(this.config.model); 41 | } 42 | 43 | /** 44 | * Clean up expired cache entries 45 | */ 46 | private cleanupCache(): void { 47 | const now = Date.now(); 48 | for (const [id, entry] of this.contentCache.entries()) { 49 | if (now - entry.timestamp > this.cacheMaxAge) { 50 | this.contentCache.delete(id); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Store content in the cache and return its ID 57 | */ 58 | private storeContent(content: string): string { 59 | const id = uuidv4(); 60 | this.contentCache.set(id, { 61 | content, 62 | timestamp: Date.now(), 63 | }); 64 | return id; 65 | } 66 | 67 | /** 68 | * Retrieve content from the cache by ID 69 | */ 70 | getFullContent(id: string): string | null { 71 | const entry = this.contentCache.get(id); 72 | if (!entry) { 73 | return null; 74 | } 75 | // Check if entry has expired 76 | if (Date.now() - entry.timestamp > this.cacheMaxAge) { 77 | this.contentCache.delete(id); 78 | return null; 79 | } 80 | return entry.content; 81 | } 82 | 83 | /** 84 | * Summarize content if it exceeds the threshold 85 | * @returns The original content if below threshold, or a summary with the original content's ID 86 | */ 87 | async maybeSummarize( 88 | content: string, 89 | type: string, 90 | options?: { 91 | hint?: string; 92 | output_format?: string; 93 | } 94 | ): Promise<{ 95 | text: string; 96 | id?: string; 97 | isSummarized: boolean; 98 | }> { 99 | if (content.length <= this.charThreshold) { 100 | return { text: content, isSummarized: false }; 101 | } 102 | 103 | const summary = await this.model.summarize(content, type); 104 | const id = this.storeContent(content); 105 | return { 106 | text: summary, 107 | id, 108 | isSummarized: true, 109 | }; 110 | } 111 | 112 | /** 113 | * Clean up resources 114 | */ 115 | async cleanup(): Promise { 116 | await this.model.cleanup(); 117 | this.contentCache.clear(); 118 | clearInterval(this.cleanupInterval); 119 | } 120 | } -------------------------------------------------------------------------------- /src/types/models.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Options for customizing summarization behavior 3 | */ 4 | export interface SummarizationOptions { 5 | /** 6 | * Provides context about what aspects of the content to focus on 7 | * Examples: "security_analysis", "api_surface", "error_handling", "dependencies", "type_definitions" 8 | */ 9 | hint?: string; 10 | 11 | /** 12 | * Specifies the desired format of the summary 13 | * Examples: "text" (default), "json", "markdown", "outline" 14 | */ 15 | output_format?: string; 16 | } 17 | 18 | /** 19 | * Configuration options for summarization models 20 | */ 21 | export interface ModelConfig { 22 | apiKey: string; 23 | maxTokens?: number; 24 | model?: string; 25 | baseUrl?: string | null; 26 | } 27 | 28 | /** 29 | * Interface that all summarization models must implement 30 | */ 31 | export interface SummarizationModel { 32 | /** 33 | * Initialize the model with configuration 34 | */ 35 | initialize(config: ModelConfig): Promise; 36 | 37 | /** 38 | * Summarize the given content 39 | * @param content The text content to summarize 40 | * @param type The type of content being summarized (e.g., "command output", "code") 41 | * @param options Optional parameters to customize summarization 42 | * @returns A summary of the content 43 | */ 44 | summarize(content: string, type: string, options?: SummarizationOptions): Promise; 45 | 46 | /** 47 | * Clean up any resources used by the model 48 | */ 49 | cleanup(): Promise; 50 | } 51 | 52 | /** 53 | * Base configuration for the summarization service 54 | */ 55 | export interface SummarizationConfig { 56 | model: ModelConfig; 57 | charThreshold?: number; 58 | cacheMaxAge?: number; 59 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "build", 11 | "declaration": true, 12 | "allowJs": true, 13 | "resolveJsonModule": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "src/*": ["src/*"] 17 | } 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules", "build", "src/__tests__", "**/*.test.ts"] 21 | } 22 | 23 | --------------------------------------------------------------------------------