├── .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 | [](https://smithery.ai/server/mcp-summarization-functions)
13 | [](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 | 
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 |
--------------------------------------------------------------------------------