Skip to content

Commit 2d9db93

Browse files
mkreymanclaude
andcommitted
Fix token limit enforcement in context_get to prevent MCP protocol errors
- Added calculateSafeItemCount() helper function to determine safe result size - Implemented automatic response truncation when approaching 25,000 token limit - Enhanced pagination metadata with truncated/truncatedCount fields - Improved warning messages with specific pagination instructions - Added comprehensive unit tests for token limit enforcement - Bumped version to 0.10.1 - Updated CHANGELOG.md with fix details This prevents "response exceeds maximum allowed tokens" errors that users were experiencing with large result sets like "*test*" pattern queries. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent d42ba7a commit 2d9db93

File tree

4 files changed

+271
-13
lines changed

4 files changed

+271
-13
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.10.1] - 2025-01-11
11+
1012
### Fixed
1113

14+
- **Token Limit Enforcement** - Fixed MCP protocol token limit errors
15+
16+
- Added automatic response truncation when approaching 25,000 token limit
17+
- Implemented `calculateSafeItemCount()` helper to determine safe result size
18+
- Enhanced pagination metadata with `truncated` and `truncatedCount` fields
19+
- Improved warning messages with specific pagination instructions
20+
- Prevents "response exceeds maximum allowed tokens" errors from MCP clients
21+
1222
- **Pagination Defaults in context_get** - Improved consistency
1323
- Added proper validation of pagination parameters at handler level
1424
- Default limit of 100 items now properly applied when not specified

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcp-memory-keeper",
3-
"version": "0.10.0",
3+
"version": "0.10.1",
44
"description": "MCP server for persistent context management in AI coding assistants",
55
"main": "dist/index.js",
66
"bin": {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
3+
// Helper functions from the main index.ts file
4+
function estimateTokens(text: string): number {
5+
return Math.ceil(text.length / 4);
6+
}
7+
8+
function calculateSafeItemCount(items: any[], tokenLimit: number): number {
9+
if (items.length === 0) return 0;
10+
11+
let safeCount = 0;
12+
let currentTokens = 0;
13+
14+
// Include base response structure in token calculation
15+
const baseResponse = {
16+
items: [],
17+
pagination: {
18+
total: 0,
19+
returned: 0,
20+
offset: 0,
21+
hasMore: false,
22+
nextOffset: null,
23+
totalCount: 0,
24+
page: 1,
25+
pageSize: 0,
26+
totalPages: 1,
27+
hasNextPage: false,
28+
hasPreviousPage: false,
29+
previousOffset: null,
30+
totalSize: 0,
31+
averageSize: 0,
32+
defaultsApplied: {},
33+
truncated: false,
34+
truncatedCount: 0,
35+
},
36+
};
37+
38+
// Estimate tokens for base response structure
39+
const baseTokens = estimateTokens(JSON.stringify(baseResponse, null, 2));
40+
currentTokens = baseTokens;
41+
42+
// Add items one by one until we approach the token limit
43+
for (let i = 0; i < items.length; i++) {
44+
const itemTokens = estimateTokens(JSON.stringify(items[i], null, 2));
45+
46+
// Leave some buffer (10%) to account for formatting and additional metadata
47+
if (currentTokens + itemTokens > tokenLimit * 0.9) {
48+
break;
49+
}
50+
51+
currentTokens += itemTokens;
52+
safeCount++;
53+
}
54+
55+
// Always return at least 1 item if any exist, even if it exceeds limit
56+
// This prevents infinite loops and ensures progress
57+
return Math.max(safeCount, items.length > 0 ? 1 : 0);
58+
}
59+
60+
describe('Token Limit Enforcement Unit Tests', () => {
61+
describe('calculateSafeItemCount', () => {
62+
it('should return 0 for empty items array', () => {
63+
const result = calculateSafeItemCount([], 20000);
64+
expect(result).toBe(0);
65+
});
66+
67+
it('should return at least 1 item if any exist', () => {
68+
const largeItem = {
69+
key: 'large.item',
70+
value: 'X'.repeat(100000), // Very large item
71+
category: 'test',
72+
priority: 'high',
73+
};
74+
75+
const result = calculateSafeItemCount([largeItem], 20000);
76+
expect(result).toBe(1);
77+
});
78+
79+
it('should truncate items when approaching token limit', () => {
80+
// Create multiple medium-sized items
81+
const items = [];
82+
for (let i = 0; i < 50; i++) {
83+
items.push({
84+
key: `item.${i}`,
85+
value:
86+
'This is a medium-sized test value that contains enough text to trigger token limit enforcement when many items are returned together. '.repeat(
87+
20
88+
),
89+
category: 'test',
90+
priority: 'high',
91+
});
92+
}
93+
94+
const result = calculateSafeItemCount(items, 20000);
95+
expect(result).toBeLessThan(50);
96+
expect(result).toBeGreaterThan(0);
97+
});
98+
99+
it('should handle small items that all fit within limit', () => {
100+
const items = [];
101+
for (let i = 0; i < 10; i++) {
102+
items.push({
103+
key: `small.item.${i}`,
104+
value: 'Small value',
105+
category: 'test',
106+
priority: 'high',
107+
});
108+
}
109+
110+
const result = calculateSafeItemCount(items, 20000);
111+
expect(result).toBe(10);
112+
});
113+
114+
it('should respect token limit with buffer', () => {
115+
// Create items that would exceed token limit
116+
const items = [];
117+
const itemValue = 'X'.repeat(2000); // 2KB item that will definitely cause truncation
118+
119+
for (let i = 0; i < 100; i++) {
120+
items.push({
121+
key: `large.buffer.item.${i}`,
122+
value: itemValue,
123+
category: 'test',
124+
priority: 'high',
125+
});
126+
}
127+
128+
const result = calculateSafeItemCount(items, 20000);
129+
130+
// Should be significantly less than all items due to token limits
131+
expect(result).toBeLessThan(100);
132+
expect(result).toBeGreaterThan(0);
133+
134+
// Verify that the result respects the buffer by checking actual tokens
135+
const actualTokens = result * estimateTokens(JSON.stringify(items[0], null, 2));
136+
expect(actualTokens).toBeLessThan(20000 * 0.9); // Should be under 90% of limit
137+
});
138+
});
139+
140+
describe('estimateTokens', () => {
141+
it('should estimate tokens correctly', () => {
142+
const text = 'This is a test string';
143+
const tokens = estimateTokens(text);
144+
expect(tokens).toBe(Math.ceil(text.length / 4));
145+
});
146+
147+
it('should handle empty strings', () => {
148+
const tokens = estimateTokens('');
149+
expect(tokens).toBe(0);
150+
});
151+
152+
it('should handle large strings', () => {
153+
const largeText = 'X'.repeat(10000);
154+
const tokens = estimateTokens(largeText);
155+
expect(tokens).toBe(2500); // 10000 / 4
156+
});
157+
});
158+
});

src/index.ts

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,59 @@ function calculateResponseMetrics(items: any[]): {
184184
return { totalSize, estimatedTokens, averageSize };
185185
}
186186

187+
// Helper to calculate how many items can fit within token limit
188+
function calculateSafeItemCount(items: any[], tokenLimit: number): number {
189+
if (items.length === 0) return 0;
190+
191+
let safeCount = 0;
192+
let currentTokens = 0;
193+
194+
// Include base response structure in token calculation
195+
const baseResponse = {
196+
items: [],
197+
pagination: {
198+
total: 0,
199+
returned: 0,
200+
offset: 0,
201+
hasMore: false,
202+
nextOffset: null,
203+
totalCount: 0,
204+
page: 1,
205+
pageSize: 0,
206+
totalPages: 1,
207+
hasNextPage: false,
208+
hasPreviousPage: false,
209+
previousOffset: null,
210+
totalSize: 0,
211+
averageSize: 0,
212+
defaultsApplied: {},
213+
truncated: false,
214+
truncatedCount: 0,
215+
},
216+
};
217+
218+
// Estimate tokens for base response structure
219+
const baseTokens = estimateTokens(JSON.stringify(baseResponse, null, 2));
220+
currentTokens = baseTokens;
221+
222+
// Add items one by one until we approach the token limit
223+
for (let i = 0; i < items.length; i++) {
224+
const itemTokens = estimateTokens(JSON.stringify(items[i], null, 2));
225+
226+
// Leave some buffer (10%) to account for formatting and additional metadata
227+
if (currentTokens + itemTokens > tokenLimit * 0.9) {
228+
break;
229+
}
230+
231+
currentTokens += itemTokens;
232+
safeCount++;
233+
}
234+
235+
// Always return at least 1 item if any exist, even if it exceeds limit
236+
// This prevents infinite loops and ensures progress
237+
return Math.max(safeCount, items.length > 0 ? 1 : 0);
238+
}
239+
187240
// Helper to parse relative time strings
188241
function parseRelativeTime(relativeTime: string): string | null {
189242
const now = new Date();
@@ -699,8 +752,22 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
699752
const metrics = calculateResponseMetrics(result.items);
700753
const TOKEN_LIMIT = 20000; // Conservative limit to stay well under MCP's 25k limit
701754

702-
// Check if we're approaching token limits
755+
// Check if we're approaching token limits and enforce truncation
703756
const isApproachingLimit = metrics.estimatedTokens > TOKEN_LIMIT;
757+
let actualItems = result.items;
758+
let wasTruncated = false;
759+
let truncatedCount = 0;
760+
761+
if (isApproachingLimit) {
762+
// Calculate how many items we can safely return
763+
const safeItemCount = calculateSafeItemCount(result.items, TOKEN_LIMIT);
764+
765+
if (safeItemCount < result.items.length) {
766+
actualItems = result.items.slice(0, safeItemCount);
767+
wasTruncated = true;
768+
truncatedCount = result.items.length - safeItemCount;
769+
}
770+
}
704771

705772
// Calculate pagination metadata
706773
// Use the validated limit and offset from paginationValidation
@@ -709,9 +776,18 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
709776
const currentPage =
710777
effectiveLimit > 0 ? Math.floor(effectiveOffset / effectiveLimit) + 1 : 1;
711778
const totalPages = effectiveLimit > 0 ? Math.ceil(result.totalCount / effectiveLimit) : 1;
712-
const hasNextPage = currentPage < totalPages;
779+
780+
// Update pagination to account for truncation
781+
const hasNextPage = wasTruncated || currentPage < totalPages;
713782
const hasPreviousPage = currentPage > 1;
714783

784+
// Calculate next offset accounting for truncation
785+
const nextOffset = hasNextPage
786+
? wasTruncated
787+
? effectiveOffset + actualItems.length
788+
: effectiveOffset + effectiveLimit
789+
: null;
790+
715791
// Track whether defaults were applied
716792
const defaultsApplied = {
717793
limit: rawLimit === undefined,
@@ -720,7 +796,7 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
720796

721797
// Enhanced response format
722798
if (includeMetadata) {
723-
const itemsWithMetadata = result.items.map(item => ({
799+
const itemsWithMetadata = actualItems.map(item => ({
724800
key: item.key,
725801
value: item.value,
726802
category: item.category,
@@ -736,10 +812,10 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
736812
items: itemsWithMetadata,
737813
pagination: {
738814
total: result.totalCount,
739-
returned: result.items.length,
815+
returned: actualItems.length,
740816
offset: effectiveOffset,
741817
hasMore: hasNextPage,
742-
nextOffset: hasNextPage ? effectiveOffset + effectiveLimit : null,
818+
nextOffset: nextOffset,
743819
// Extended pagination metadata
744820
totalCount: result.totalCount,
745821
page: currentPage,
@@ -755,13 +831,20 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
755831
averageSize: metrics.averageSize,
756832
// Defaults applied
757833
defaultsApplied: defaultsApplied,
834+
// Truncation information
835+
truncated: wasTruncated,
836+
truncatedCount: truncatedCount,
758837
},
759838
};
760839

761840
// Add warning if approaching token limits
762841
if (isApproachingLimit) {
763-
response.pagination.warning =
764-
'Large result set. Consider using smaller limit or more specific filters.';
842+
if (wasTruncated) {
843+
response.pagination.warning = `Response truncated due to token limits. ${truncatedCount} items omitted. Use pagination with offset=${nextOffset} to retrieve remaining items.`;
844+
} else {
845+
response.pagination.warning =
846+
'Large result set. Consider using smaller limit or more specific filters.';
847+
}
765848
}
766849

767850
return {
@@ -776,20 +859,27 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
776859

777860
// Return enhanced format for all queries to support pagination
778861
const response: any = {
779-
items: result.items,
862+
items: actualItems,
780863
pagination: {
781864
total: result.totalCount,
782-
returned: result.items.length,
865+
returned: actualItems.length,
783866
offset: effectiveOffset,
784867
hasMore: hasNextPage,
785-
nextOffset: hasNextPage ? effectiveOffset + effectiveLimit : null,
868+
nextOffset: nextOffset,
869+
// Truncation information
870+
truncated: wasTruncated,
871+
truncatedCount: truncatedCount,
786872
},
787873
};
788874

789875
// Add warning if approaching token limits
790876
if (isApproachingLimit) {
791-
response.pagination.warning =
792-
'Large result set. Consider using smaller limit or more specific filters.';
877+
if (wasTruncated) {
878+
response.pagination.warning = `Response truncated due to token limits. ${truncatedCount} items omitted. Use pagination with offset=${nextOffset} to retrieve remaining items.`;
879+
} else {
880+
response.pagination.warning =
881+
'Large result set. Consider using smaller limit or more specific filters.';
882+
}
793883
}
794884

795885
return {

0 commit comments

Comments
 (0)