|
5 | 5 | import { describe, it, expect, vi, beforeEach } from 'vitest'; |
6 | 6 | import type { Logger } from '../core/logger.js'; |
7 | 7 | import { sanitizeToolList, applyProviderToolPolicy, isToolAllowedByProviderPolicy, isValidUpstreamToolName } from './upstream-tool-sanitizer.js'; |
| 8 | +import type { ToolDescriptionLengthPolicy } from './upstream-tool-sanitizer.js'; |
8 | 9 | import type { Tool } from '@modelcontextprotocol/sdk/types.js'; |
9 | 10 |
|
10 | 11 | function makeTool(name: string, description?: string): Tool { |
@@ -503,6 +504,125 @@ describe('sanitizeToolList — html_description_policy', () => { |
503 | 504 | }); |
504 | 505 | }); |
505 | 506 |
|
| 507 | +describe('sanitizeToolList — tool_description_length_policy', () => { |
| 508 | + const LONG_DESC = 'a'.repeat(2049); |
| 509 | + const EXACT_DESC = 'a'.repeat(2048); |
| 510 | + let logger: Logger; |
| 511 | + |
| 512 | + beforeEach(() => { |
| 513 | + logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; |
| 514 | + }); |
| 515 | + |
| 516 | + describe('drop (default)', () => { |
| 517 | + it('drops tool with description > 2048 chars', () => { |
| 518 | + const tool = makeTool('t', LONG_DESC); |
| 519 | + const result = sanitizeToolList([tool], logger, 'drop', 'drop'); |
| 520 | + expect(result.tools).toHaveLength(0); |
| 521 | + expect(result.dropped[0].reason).toBe('tool description too long'); |
| 522 | + }); |
| 523 | + |
| 524 | + it('passes tool with description exactly 2048 chars', () => { |
| 525 | + const tool = makeTool('t', EXACT_DESC); |
| 526 | + const result = sanitizeToolList([tool], logger, 'drop', 'drop'); |
| 527 | + expect(result.tools).toHaveLength(1); |
| 528 | + expect(result.dropped).toHaveLength(0); |
| 529 | + }); |
| 530 | + }); |
| 531 | + |
| 532 | + describe('truncate', () => { |
| 533 | + it('keeps tool and truncates description to 2048 chars', () => { |
| 534 | + const tool = makeTool('t', LONG_DESC); |
| 535 | + const result = sanitizeToolList([tool], logger, 'drop', 'truncate'); |
| 536 | + expect(result.tools).toHaveLength(1); |
| 537 | + expect(result.tools[0].description).toHaveLength(2048); |
| 538 | + expect(result.tools[0].description).toBe(LONG_DESC.slice(0, 2048)); |
| 539 | + expect(result.dropped).toHaveLength(0); |
| 540 | + expect(logger.warn).not.toHaveBeenCalled(); |
| 541 | + }); |
| 542 | + |
| 543 | + it('does not mutate original tool object', () => { |
| 544 | + const tool = makeTool('t', LONG_DESC); |
| 545 | + sanitizeToolList([tool], logger, 'drop', 'truncate'); |
| 546 | + expect(tool.description).toBe(LONG_DESC); |
| 547 | + }); |
| 548 | + |
| 549 | + it('passes tool with description exactly 2048 chars unchanged', () => { |
| 550 | + const tool = makeTool('t', EXACT_DESC); |
| 551 | + const result = sanitizeToolList([tool], logger, 'drop', 'truncate'); |
| 552 | + expect(result.tools).toHaveLength(1); |
| 553 | + expect(result.tools[0].description).toHaveLength(2048); |
| 554 | + }); |
| 555 | + |
| 556 | + it('still drops tool with invalid name regardless of truncate policy', () => { |
| 557 | + const tool = makeTool('bad name!', LONG_DESC); |
| 558 | + const result = sanitizeToolList([tool], logger, 'drop', 'truncate'); |
| 559 | + expect(result.tools).toHaveLength(0); |
| 560 | + expect(result.dropped[0].reason).toBe('invalid characters in tool name'); |
| 561 | + }); |
| 562 | + |
| 563 | + it('still drops tool with malformed inputSchema regardless of truncate policy', () => { |
| 564 | + const tool = { name: 'valid', description: LONG_DESC, inputSchema: null } as unknown as Tool; |
| 565 | + const result = sanitizeToolList([tool], logger, 'drop', 'truncate'); |
| 566 | + expect(result.tools).toHaveLength(0); |
| 567 | + expect(result.dropped[0].reason).toBe('malformed tool definition: inputSchema is not an object'); |
| 568 | + }); |
| 569 | + }); |
| 570 | + |
| 571 | + describe('allow', () => { |
| 572 | + it('passes tool with overlong description through unchanged', () => { |
| 573 | + const tool = makeTool('t', LONG_DESC); |
| 574 | + const result = sanitizeToolList([tool], logger, 'drop', 'allow'); |
| 575 | + expect(result.tools).toHaveLength(1); |
| 576 | + expect(result.tools[0].description).toBe(LONG_DESC); |
| 577 | + expect(result.dropped).toHaveLength(0); |
| 578 | + expect(logger.warn).not.toHaveBeenCalled(); |
| 579 | + }); |
| 580 | + |
| 581 | + it('still drops tool with invalid name regardless of allow policy', () => { |
| 582 | + const tool = makeTool('bad name!', LONG_DESC); |
| 583 | + const result = sanitizeToolList([tool], logger, 'drop', 'allow'); |
| 584 | + expect(result.tools).toHaveLength(0); |
| 585 | + expect(result.dropped[0].reason).toBe('invalid characters in tool name'); |
| 586 | + }); |
| 587 | + |
| 588 | + it('still drops tool with malformed inputSchema regardless of allow policy', () => { |
| 589 | + const tool = { name: 'valid', description: LONG_DESC, inputSchema: null } as unknown as Tool; |
| 590 | + const result = sanitizeToolList([tool], logger, 'drop', 'allow'); |
| 591 | + expect(result.tools).toHaveLength(0); |
| 592 | + expect(result.dropped[0].reason).toBe('malformed tool definition: inputSchema is not an object'); |
| 593 | + }); |
| 594 | + }); |
| 595 | + |
| 596 | + describe('interactions with html_description_policy', () => { |
| 597 | + it('truncate length + strip html: truncates first then strips HTML from truncated string', () => { |
| 598 | + // desc = '<b>' + 'a' * 2049 + '</b>' = 2056 chars |
| 599 | + // truncate to 2048: '<b>' + 'a' * 2045 (first 2048 chars) |
| 600 | + // then strip HTML: 'a' * 2045 |
| 601 | + const desc = '<b>' + 'a'.repeat(2049) + '</b>'; |
| 602 | + const tool = makeTool('t', desc); |
| 603 | + const result = sanitizeToolList([tool], logger, 'strip', 'truncate'); |
| 604 | + expect(result.tools).toHaveLength(1); |
| 605 | + expect(result.tools[0].description).toBe('a'.repeat(2045)); |
| 606 | + expect(result.dropped).toHaveLength(0); |
| 607 | + }); |
| 608 | + |
| 609 | + it('allow length + drop html: still drops tool with HTML chars in description', () => { |
| 610 | + const tool = makeTool('t', '<b>' + 'a'.repeat(2049) + '</b>'); |
| 611 | + const result = sanitizeToolList([tool], logger, 'drop', 'allow'); |
| 612 | + expect(result.tools).toHaveLength(0); |
| 613 | + expect(result.dropped[0].reason).toBe('forbidden characters in description'); |
| 614 | + }); |
| 615 | + |
| 616 | + it('allow length + allow html: passes overlong HTML description through unchanged', () => { |
| 617 | + const desc = '<b>' + 'a'.repeat(2049) + '</b>'; |
| 618 | + const tool = makeTool('t', desc); |
| 619 | + const result = sanitizeToolList([tool], logger, 'allow', 'allow'); |
| 620 | + expect(result.tools).toHaveLength(1); |
| 621 | + expect(result.tools[0].description).toBe(desc); |
| 622 | + }); |
| 623 | + }); |
| 624 | +}); |
| 625 | + |
506 | 626 | describe('applyProviderToolPolicy', () => { |
507 | 627 | const tools = [makeTool('alpha'), makeTool('beta'), makeTool('gamma')]; |
508 | 628 |
|
|
0 commit comments