@vinkius-core/mcp-fusion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +391 -0
- package/dist/src/AbstractBase.d.ts +24 -0
- package/dist/src/AbstractBase.d.ts.map +1 -0
- package/dist/src/AbstractBase.js +63 -0
- package/dist/src/AbstractBase.js.map +1 -0
- package/dist/src/AbstractLeaf.d.ts +12 -0
- package/dist/src/AbstractLeaf.d.ts.map +1 -0
- package/dist/src/AbstractLeaf.js +32 -0
- package/dist/src/AbstractLeaf.js.map +1 -0
- package/dist/src/Annotations.d.ts +15 -0
- package/dist/src/Annotations.d.ts.map +1 -0
- package/dist/src/Annotations.js +29 -0
- package/dist/src/Annotations.js.map +1 -0
- package/dist/src/Group.d.ts +32 -0
- package/dist/src/Group.d.ts.map +1 -0
- package/dist/src/Group.js +131 -0
- package/dist/src/Group.js.map +1 -0
- package/dist/src/Icon.d.ts +19 -0
- package/dist/src/Icon.d.ts.map +1 -0
- package/dist/src/Icon.js +33 -0
- package/dist/src/Icon.js.map +1 -0
- package/dist/src/Prompt.d.ts +11 -0
- package/dist/src/Prompt.d.ts.map +1 -0
- package/dist/src/Prompt.js +28 -0
- package/dist/src/Prompt.js.map +1 -0
- package/dist/src/PromptArgument.d.ts +10 -0
- package/dist/src/PromptArgument.d.ts.map +1 -0
- package/dist/src/PromptArgument.js +20 -0
- package/dist/src/PromptArgument.js.map +1 -0
- package/dist/src/Resource.d.ts +19 -0
- package/dist/src/Resource.d.ts.map +1 -0
- package/dist/src/Resource.js +34 -0
- package/dist/src/Resource.js.map +1 -0
- package/dist/src/Role.d.ts +5 -0
- package/dist/src/Role.d.ts.map +1 -0
- package/dist/src/Role.js +6 -0
- package/dist/src/Role.js.map +1 -0
- package/dist/src/Tool.d.ts +16 -0
- package/dist/src/Tool.d.ts.map +1 -0
- package/dist/src/Tool.js +28 -0
- package/dist/src/Tool.js.map +1 -0
- package/dist/src/ToolAnnotations.d.ts +23 -0
- package/dist/src/ToolAnnotations.d.ts.map +1 -0
- package/dist/src/ToolAnnotations.js +44 -0
- package/dist/src/ToolAnnotations.js.map +1 -0
- package/dist/src/converters/GroupConverter.d.ts +14 -0
- package/dist/src/converters/GroupConverter.d.ts.map +1 -0
- package/dist/src/converters/GroupConverter.js +13 -0
- package/dist/src/converters/GroupConverter.js.map +1 -0
- package/dist/src/converters/PromptConverter.d.ts +14 -0
- package/dist/src/converters/PromptConverter.d.ts.map +1 -0
- package/dist/src/converters/PromptConverter.js +13 -0
- package/dist/src/converters/PromptConverter.js.map +1 -0
- package/dist/src/converters/ResourceConverter.d.ts +14 -0
- package/dist/src/converters/ResourceConverter.d.ts.map +1 -0
- package/dist/src/converters/ResourceConverter.js +13 -0
- package/dist/src/converters/ResourceConverter.js.map +1 -0
- package/dist/src/converters/ToolAnnotationsConverter.d.ts +16 -0
- package/dist/src/converters/ToolAnnotationsConverter.d.ts.map +1 -0
- package/dist/src/converters/ToolAnnotationsConverter.js +23 -0
- package/dist/src/converters/ToolAnnotationsConverter.js.map +1 -0
- package/dist/src/converters/ToolConverter.d.ts +14 -0
- package/dist/src/converters/ToolConverter.d.ts.map +1 -0
- package/dist/src/converters/ToolConverter.js +13 -0
- package/dist/src/converters/ToolConverter.js.map +1 -0
- package/dist/src/converters/index.d.ts +6 -0
- package/dist/src/converters/index.d.ts.map +1 -0
- package/dist/src/converters/index.js +6 -0
- package/dist/src/converters/index.js.map +1 -0
- package/dist/src/framework/GroupedToolBuilder.d.ts +137 -0
- package/dist/src/framework/GroupedToolBuilder.d.ts.map +1 -0
- package/dist/src/framework/GroupedToolBuilder.js +289 -0
- package/dist/src/framework/GroupedToolBuilder.js.map +1 -0
- package/dist/src/framework/ResponseHelper.d.ts +43 -0
- package/dist/src/framework/ResponseHelper.d.ts.map +1 -0
- package/dist/src/framework/ResponseHelper.js +49 -0
- package/dist/src/framework/ResponseHelper.js.map +1 -0
- package/dist/src/framework/ToolBuilder.d.ts +46 -0
- package/dist/src/framework/ToolBuilder.d.ts.map +1 -0
- package/dist/src/framework/ToolBuilder.js +2 -0
- package/dist/src/framework/ToolBuilder.js.map +1 -0
- package/dist/src/framework/ToolRegistry.d.ts +85 -0
- package/dist/src/framework/ToolRegistry.d.ts.map +1 -0
- package/dist/src/framework/ToolRegistry.js +153 -0
- package/dist/src/framework/ToolRegistry.js.map +1 -0
- package/dist/src/framework/index.d.ts +9 -0
- package/dist/src/framework/index.d.ts.map +1 -0
- package/dist/src/framework/index.js +8 -0
- package/dist/src/framework/index.js.map +1 -0
- package/dist/src/framework/strategies/AnnotationAggregator.d.ts +11 -0
- package/dist/src/framework/strategies/AnnotationAggregator.d.ts.map +1 -0
- package/dist/src/framework/strategies/AnnotationAggregator.js +25 -0
- package/dist/src/framework/strategies/AnnotationAggregator.js.map +1 -0
- package/dist/src/framework/strategies/DescriptionGenerator.d.ts +12 -0
- package/dist/src/framework/strategies/DescriptionGenerator.d.ts.map +1 -0
- package/dist/src/framework/strategies/DescriptionGenerator.js +70 -0
- package/dist/src/framework/strategies/DescriptionGenerator.js.map +1 -0
- package/dist/src/framework/strategies/MiddlewareCompiler.d.ts +13 -0
- package/dist/src/framework/strategies/MiddlewareCompiler.d.ts.map +1 -0
- package/dist/src/framework/strategies/MiddlewareCompiler.js +24 -0
- package/dist/src/framework/strategies/MiddlewareCompiler.js.map +1 -0
- package/dist/src/framework/strategies/SchemaGenerator.d.ts +15 -0
- package/dist/src/framework/strategies/SchemaGenerator.d.ts.map +1 -0
- package/dist/src/framework/strategies/SchemaGenerator.js +97 -0
- package/dist/src/framework/strategies/SchemaGenerator.js.map +1 -0
- package/dist/src/framework/strategies/SchemaUtils.d.ts +7 -0
- package/dist/src/framework/strategies/SchemaUtils.d.ts.map +1 -0
- package/dist/src/framework/strategies/SchemaUtils.js +17 -0
- package/dist/src/framework/strategies/SchemaUtils.js.map +1 -0
- package/dist/src/framework/strategies/ToonDescriptionGenerator.d.ts +3 -0
- package/dist/src/framework/strategies/ToonDescriptionGenerator.d.ts.map +1 -0
- package/dist/src/framework/strategies/ToonDescriptionGenerator.js +53 -0
- package/dist/src/framework/strategies/ToonDescriptionGenerator.js.map +1 -0
- package/dist/src/framework/strategies/Types.d.ts +34 -0
- package/dist/src/framework/strategies/Types.d.ts.map +1 -0
- package/dist/src/framework/strategies/Types.js +2 -0
- package/dist/src/framework/strategies/Types.js.map +1 -0
- package/dist/src/framework/strategies/index.d.ts +12 -0
- package/dist/src/framework/strategies/index.d.ts.map +1 -0
- package/dist/src/framework/strategies/index.js +11 -0
- package/dist/src/framework/strategies/index.js.map +1 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +15 -0
- package/dist/src/index.js.map +1 -0
- package/dist/tests/AbstractBase.test.d.ts +2 -0
- package/dist/tests/AbstractBase.test.d.ts.map +1 -0
- package/dist/tests/AbstractBase.test.js +130 -0
- package/dist/tests/AbstractBase.test.js.map +1 -0
- package/dist/tests/AbstractLeaf.test.d.ts +2 -0
- package/dist/tests/AbstractLeaf.test.d.ts.map +1 -0
- package/dist/tests/AbstractLeaf.test.js +65 -0
- package/dist/tests/AbstractLeaf.test.js.map +1 -0
- package/dist/tests/Annotations.test.d.ts +2 -0
- package/dist/tests/Annotations.test.d.ts.map +1 -0
- package/dist/tests/Annotations.test.js +34 -0
- package/dist/tests/Annotations.test.js.map +1 -0
- package/dist/tests/BarrelExport.test.d.ts +2 -0
- package/dist/tests/BarrelExport.test.d.ts.map +1 -0
- package/dist/tests/BarrelExport.test.js +42 -0
- package/dist/tests/BarrelExport.test.js.map +1 -0
- package/dist/tests/Converters.test.d.ts +2 -0
- package/dist/tests/Converters.test.d.ts.map +1 -0
- package/dist/tests/Converters.test.js +193 -0
- package/dist/tests/Converters.test.js.map +1 -0
- package/dist/tests/Group.test.d.ts +2 -0
- package/dist/tests/Group.test.d.ts.map +1 -0
- package/dist/tests/Group.test.js +257 -0
- package/dist/tests/Group.test.js.map +1 -0
- package/dist/tests/Icon.test.d.ts +2 -0
- package/dist/tests/Icon.test.d.ts.map +1 -0
- package/dist/tests/Icon.test.js +44 -0
- package/dist/tests/Icon.test.js.map +1 -0
- package/dist/tests/Prompt.test.d.ts +2 -0
- package/dist/tests/Prompt.test.d.ts.map +1 -0
- package/dist/tests/Prompt.test.js +63 -0
- package/dist/tests/Prompt.test.js.map +1 -0
- package/dist/tests/PromptArgument.test.d.ts +2 -0
- package/dist/tests/PromptArgument.test.d.ts.map +1 -0
- package/dist/tests/PromptArgument.test.js +37 -0
- package/dist/tests/PromptArgument.test.js.map +1 -0
- package/dist/tests/Resource.test.d.ts +2 -0
- package/dist/tests/Resource.test.d.ts.map +1 -0
- package/dist/tests/Resource.test.js +61 -0
- package/dist/tests/Resource.test.js.map +1 -0
- package/dist/tests/Role.test.d.ts +2 -0
- package/dist/tests/Role.test.d.ts.map +1 -0
- package/dist/tests/Role.test.js +17 -0
- package/dist/tests/Role.test.js.map +1 -0
- package/dist/tests/Tool.test.d.ts +2 -0
- package/dist/tests/Tool.test.d.ts.map +1 -0
- package/dist/tests/Tool.test.js +62 -0
- package/dist/tests/Tool.test.js.map +1 -0
- package/dist/tests/ToolAnnotations.test.d.ts +2 -0
- package/dist/tests/ToolAnnotations.test.d.ts.map +1 -0
- package/dist/tests/ToolAnnotations.test.js +55 -0
- package/dist/tests/ToolAnnotations.test.js.map +1 -0
- package/dist/tests/framework/AdversarialQA.test.d.ts +2 -0
- package/dist/tests/framework/AdversarialQA.test.d.ts.map +1 -0
- package/dist/tests/framework/AdversarialQA.test.js +906 -0
- package/dist/tests/framework/AdversarialQA.test.js.map +1 -0
- package/dist/tests/framework/GroupedToolBuilder.test.d.ts +2 -0
- package/dist/tests/framework/GroupedToolBuilder.test.d.ts.map +1 -0
- package/dist/tests/framework/GroupedToolBuilder.test.js +712 -0
- package/dist/tests/framework/GroupedToolBuilder.test.js.map +1 -0
- package/dist/tests/framework/LargeScaleScenarios.test.d.ts +2 -0
- package/dist/tests/framework/LargeScaleScenarios.test.d.ts.map +1 -0
- package/dist/tests/framework/LargeScaleScenarios.test.js +1043 -0
- package/dist/tests/framework/LargeScaleScenarios.test.js.map +1 -0
- package/dist/tests/framework/McpServerAdapter.test.d.ts +2 -0
- package/dist/tests/framework/McpServerAdapter.test.d.ts.map +1 -0
- package/dist/tests/framework/McpServerAdapter.test.js +380 -0
- package/dist/tests/framework/McpServerAdapter.test.js.map +1 -0
- package/dist/tests/framework/ResponseHelper.test.d.ts +2 -0
- package/dist/tests/framework/ResponseHelper.test.d.ts.map +1 -0
- package/dist/tests/framework/ResponseHelper.test.js +202 -0
- package/dist/tests/framework/ResponseHelper.test.js.map +1 -0
- package/dist/tests/framework/SecurityDeep.test.d.ts +2 -0
- package/dist/tests/framework/SecurityDeep.test.d.ts.map +1 -0
- package/dist/tests/framework/SecurityDeep.test.js +825 -0
- package/dist/tests/framework/SecurityDeep.test.js.map +1 -0
- package/dist/tests/framework/ToolRegistry.test.d.ts +2 -0
- package/dist/tests/framework/ToolRegistry.test.d.ts.map +1 -0
- package/dist/tests/framework/ToolRegistry.test.js +152 -0
- package/dist/tests/framework/ToolRegistry.test.js.map +1 -0
- package/dist/tests/framework/ToonDescription.test.d.ts +2 -0
- package/dist/tests/framework/ToonDescription.test.d.ts.map +1 -0
- package/dist/tests/framework/ToonDescription.test.js +287 -0
- package/dist/tests/framework/ToonDescription.test.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdversarialQA.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Anthropic-level Quality Assurance tests for the MCP Tool Consolidation
|
|
5
|
+
* Framework. These tests are designed to probe invariants, contracts,
|
|
6
|
+
* security boundaries, state machine integrity, and protocol compliance
|
|
7
|
+
* at a level expected of production AI infrastructure.
|
|
8
|
+
*
|
|
9
|
+
* Categories:
|
|
10
|
+
* 1. Builder State Machine — frozen/unfrozen transitions, caching
|
|
11
|
+
* 2. MCP Protocol Contract — response shape compliance for all paths
|
|
12
|
+
* 3. Zod Defense Chain — boundary attacks on validation
|
|
13
|
+
* 4. Prototype Pollution & Injection — __proto__, constructor abuse
|
|
14
|
+
* 5. Discriminator Abuse — missing, null, numeric, object, array
|
|
15
|
+
* 6. Build Idempotency — multiple builds must return same cached ref
|
|
16
|
+
* 7. Middleware Ordering Guarantees — LIFO wrapping, correct ctx passing
|
|
17
|
+
* 8. Annotation Aggregation Invariants — all boolean combos
|
|
18
|
+
* 9. Description Generation Contract — 3-layer structure verification
|
|
19
|
+
* 10. Schema Field Annotation Accuracy — per-field annotation correctness
|
|
20
|
+
* 11. Custom Discriminator — non-default discriminator field support
|
|
21
|
+
* 12. ToolRegistry Contract — routing, registration, edge cases
|
|
22
|
+
* 13. ResponseHelper Contract — shape compliance for all helpers
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import { GroupedToolBuilder } from '../../src/framework/GroupedToolBuilder.js';
|
|
27
|
+
import { ToolRegistry } from '../../src/framework/ToolRegistry.js';
|
|
28
|
+
import { success, error, required } from '../../src/framework/ResponseHelper.js';
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// 1. Builder State Machine Invariants
|
|
31
|
+
// ============================================================================
|
|
32
|
+
describe('QA: Builder State Machine', () => {
|
|
33
|
+
it('should prevent .action() after build', () => {
|
|
34
|
+
const b = new GroupedToolBuilder('sm_test')
|
|
35
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
36
|
+
b.buildToolDefinition();
|
|
37
|
+
expect(() => b.action({ name: 'b', handler: async () => success('ok') }))
|
|
38
|
+
.toThrow(/frozen/i);
|
|
39
|
+
});
|
|
40
|
+
it('should prevent .group() after build', () => {
|
|
41
|
+
const b = new GroupedToolBuilder('sm_group')
|
|
42
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
43
|
+
b.buildToolDefinition();
|
|
44
|
+
expect(() => b.group('g', 'desc', g => g.action({ name: 'x', handler: async () => success('ok') })))
|
|
45
|
+
.toThrow(/frozen/i);
|
|
46
|
+
});
|
|
47
|
+
it('should prevent .description() after build', () => {
|
|
48
|
+
const b = new GroupedToolBuilder('sm_desc')
|
|
49
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
50
|
+
b.buildToolDefinition();
|
|
51
|
+
expect(() => b.description('new desc')).toThrow(/frozen/i);
|
|
52
|
+
});
|
|
53
|
+
it('should prevent .commonSchema() after build', () => {
|
|
54
|
+
const b = new GroupedToolBuilder('sm_schema')
|
|
55
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
56
|
+
b.buildToolDefinition();
|
|
57
|
+
expect(() => b.commonSchema(z.object({ x: z.string() }))).toThrow(/frozen/i);
|
|
58
|
+
});
|
|
59
|
+
it('should prevent .use() after build', () => {
|
|
60
|
+
const b = new GroupedToolBuilder('sm_mw')
|
|
61
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
62
|
+
b.buildToolDefinition();
|
|
63
|
+
expect(() => b.use(async (_ctx, _args, next) => next())).toThrow(/frozen/i);
|
|
64
|
+
});
|
|
65
|
+
it('should prevent .tags() after build', () => {
|
|
66
|
+
const b = new GroupedToolBuilder('sm_tags')
|
|
67
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
68
|
+
b.buildToolDefinition();
|
|
69
|
+
expect(() => b.tags('tag')).toThrow(/frozen/i);
|
|
70
|
+
});
|
|
71
|
+
it('should prevent .annotations() after build', () => {
|
|
72
|
+
const b = new GroupedToolBuilder('sm_annot')
|
|
73
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
74
|
+
b.buildToolDefinition();
|
|
75
|
+
expect(() => b.annotations({ readOnlyHint: true })).toThrow(/frozen/i);
|
|
76
|
+
});
|
|
77
|
+
it('should prevent .discriminator() after build', () => {
|
|
78
|
+
const b = new GroupedToolBuilder('sm_disc')
|
|
79
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
80
|
+
b.buildToolDefinition();
|
|
81
|
+
expect(() => b.discriminator('op')).toThrow(/frozen/i);
|
|
82
|
+
});
|
|
83
|
+
it('should throw on build with zero actions', () => {
|
|
84
|
+
const b = new GroupedToolBuilder('empty_builder');
|
|
85
|
+
expect(() => b.buildToolDefinition()).toThrow(/no actions/i);
|
|
86
|
+
});
|
|
87
|
+
it('should auto-build on first execute if not yet built', async () => {
|
|
88
|
+
const b = new GroupedToolBuilder('auto_build')
|
|
89
|
+
.action({ name: 'ping', handler: async () => success('pong') });
|
|
90
|
+
// No buildToolDefinition() — execute should trigger build
|
|
91
|
+
const result = await b.execute(undefined, { action: 'ping' });
|
|
92
|
+
expect(result.isError).toBeUndefined();
|
|
93
|
+
expect(result.content[0].text).toBe('pong');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// 2. MCP Protocol Contract — Response Shape
|
|
98
|
+
// ============================================================================
|
|
99
|
+
describe('QA: MCP Response Contract', () => {
|
|
100
|
+
let builder;
|
|
101
|
+
beforeAll(() => {
|
|
102
|
+
builder = new GroupedToolBuilder('contract_test')
|
|
103
|
+
.commonSchema(z.object({ org: z.string() }))
|
|
104
|
+
.action({
|
|
105
|
+
name: 'ok',
|
|
106
|
+
schema: z.object({ id: z.string() }),
|
|
107
|
+
handler: async () => success('result'),
|
|
108
|
+
})
|
|
109
|
+
.action({
|
|
110
|
+
name: 'fail',
|
|
111
|
+
handler: async () => { throw new Error('BOOM'); },
|
|
112
|
+
});
|
|
113
|
+
builder.buildToolDefinition();
|
|
114
|
+
});
|
|
115
|
+
function assertMcpResponse(result) {
|
|
116
|
+
expect(result).toHaveProperty('content');
|
|
117
|
+
expect(Array.isArray(result.content)).toBe(true);
|
|
118
|
+
expect(result.content.length).toBeGreaterThan(0);
|
|
119
|
+
expect(result.content[0]).toHaveProperty('type', 'text');
|
|
120
|
+
expect(typeof result.content[0].text).toBe('string');
|
|
121
|
+
}
|
|
122
|
+
it('success response must comply with MCP shape', async () => {
|
|
123
|
+
const result = await builder.execute(undefined, {
|
|
124
|
+
action: 'ok', org: 'acme', id: 'x',
|
|
125
|
+
});
|
|
126
|
+
assertMcpResponse(result);
|
|
127
|
+
expect(result.isError).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
it('handler error response must comply with MCP shape', async () => {
|
|
130
|
+
const result = await builder.execute(undefined, {
|
|
131
|
+
action: 'fail', org: 'acme',
|
|
132
|
+
});
|
|
133
|
+
assertMcpResponse(result);
|
|
134
|
+
expect(result.isError).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
it('validation error response must comply with MCP shape', async () => {
|
|
137
|
+
const result = await builder.execute(undefined, {
|
|
138
|
+
action: 'ok', org: 123, // wrong type
|
|
139
|
+
});
|
|
140
|
+
assertMcpResponse(result);
|
|
141
|
+
expect(result.isError).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
it('unknown action response must comply with MCP shape', async () => {
|
|
144
|
+
const result = await builder.execute(undefined, {
|
|
145
|
+
action: 'nonexistent', org: 'x',
|
|
146
|
+
});
|
|
147
|
+
assertMcpResponse(result);
|
|
148
|
+
expect(result.isError).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
it('missing discriminator response must comply with MCP shape', async () => {
|
|
151
|
+
const result = await builder.execute(undefined, { org: 'x' });
|
|
152
|
+
assertMcpResponse(result);
|
|
153
|
+
expect(result.isError).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
it('tool definition must comply with MCP Tool shape', () => {
|
|
156
|
+
const def = builder.buildToolDefinition();
|
|
157
|
+
expect(def).toHaveProperty('name');
|
|
158
|
+
expect(typeof def.name).toBe('string');
|
|
159
|
+
expect(def).toHaveProperty('description');
|
|
160
|
+
expect(typeof def.description).toBe('string');
|
|
161
|
+
expect(def).toHaveProperty('inputSchema');
|
|
162
|
+
expect(def.inputSchema).toHaveProperty('type', 'object');
|
|
163
|
+
expect(def.inputSchema).toHaveProperty('properties');
|
|
164
|
+
expect(def.inputSchema).toHaveProperty('required');
|
|
165
|
+
expect(Array.isArray(def.inputSchema.required)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// 3. Zod Defense Chain — Boundary Attacks
|
|
170
|
+
// ============================================================================
|
|
171
|
+
describe('QA: Zod Defense Chain', () => {
|
|
172
|
+
let builder;
|
|
173
|
+
beforeAll(() => {
|
|
174
|
+
builder = new GroupedToolBuilder('zod_defense')
|
|
175
|
+
.commonSchema(z.object({
|
|
176
|
+
tenant: z.string().min(1).max(100),
|
|
177
|
+
}))
|
|
178
|
+
.action({
|
|
179
|
+
name: 'process',
|
|
180
|
+
schema: z.object({
|
|
181
|
+
count: z.number().int().min(0).max(1000000),
|
|
182
|
+
email: z.string().email(),
|
|
183
|
+
tags: z.array(z.string()).max(50).optional(),
|
|
184
|
+
}),
|
|
185
|
+
handler: async (_ctx, args) => success(`processed ${args.count}`),
|
|
186
|
+
});
|
|
187
|
+
builder.buildToolDefinition();
|
|
188
|
+
});
|
|
189
|
+
it('should reject negative count', async () => {
|
|
190
|
+
const r = await builder.execute(undefined, {
|
|
191
|
+
action: 'process', tenant: 'a', count: -1, email: 'x@y.com',
|
|
192
|
+
});
|
|
193
|
+
expect(r.isError).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
it('should reject fractional count when int expected', async () => {
|
|
196
|
+
const r = await builder.execute(undefined, {
|
|
197
|
+
action: 'process', tenant: 'a', count: 3.14, email: 'x@y.com',
|
|
198
|
+
});
|
|
199
|
+
expect(r.isError).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
it('should reject count exceeding max', async () => {
|
|
202
|
+
const r = await builder.execute(undefined, {
|
|
203
|
+
action: 'process', tenant: 'a', count: 1000001, email: 'x@y.com',
|
|
204
|
+
});
|
|
205
|
+
expect(r.isError).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
it('should reject invalid email format', async () => {
|
|
208
|
+
const r = await builder.execute(undefined, {
|
|
209
|
+
action: 'process', tenant: 'a', count: 1, email: 'not-an-email',
|
|
210
|
+
});
|
|
211
|
+
expect(r.isError).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
it('should reject empty tenant (min 1)', async () => {
|
|
214
|
+
const r = await builder.execute(undefined, {
|
|
215
|
+
action: 'process', tenant: '', count: 1, email: 'x@y.com',
|
|
216
|
+
});
|
|
217
|
+
expect(r.isError).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
it('should reject oversized tenant (max 100)', async () => {
|
|
220
|
+
const r = await builder.execute(undefined, {
|
|
221
|
+
action: 'process', tenant: 'x'.repeat(101), count: 1, email: 'x@y.com',
|
|
222
|
+
});
|
|
223
|
+
expect(r.isError).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
it('should accept valid input at exact boundaries', async () => {
|
|
226
|
+
const r = await builder.execute(undefined, {
|
|
227
|
+
action: 'process',
|
|
228
|
+
tenant: 'x', // min 1 ✓
|
|
229
|
+
count: 0, // min 0 ✓
|
|
230
|
+
email: 'a@b.co',
|
|
231
|
+
});
|
|
232
|
+
expect(r.isError).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
it('should accept valid input at max boundaries', async () => {
|
|
235
|
+
const r = await builder.execute(undefined, {
|
|
236
|
+
action: 'process',
|
|
237
|
+
tenant: 'x'.repeat(100), // max 100 ✓
|
|
238
|
+
count: 1000000, // max 1000000 ✓
|
|
239
|
+
email: 'test@example.com',
|
|
240
|
+
});
|
|
241
|
+
expect(r.isError).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
it('should strip extra fields injected by LLM', async () => {
|
|
244
|
+
const r = await builder.execute(undefined, {
|
|
245
|
+
action: 'process',
|
|
246
|
+
tenant: 'ok',
|
|
247
|
+
count: 1,
|
|
248
|
+
email: 'a@b.com',
|
|
249
|
+
__proto__: { admin: true },
|
|
250
|
+
constructor: 'hack',
|
|
251
|
+
malicious_field: 'rm -rf /',
|
|
252
|
+
});
|
|
253
|
+
// Should still succeed — extra fields stripped
|
|
254
|
+
expect(r.isError).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
it('should reject when required field is null', async () => {
|
|
257
|
+
const r = await builder.execute(undefined, {
|
|
258
|
+
action: 'process', tenant: 'ok', count: null, email: 'a@b.com',
|
|
259
|
+
});
|
|
260
|
+
expect(r.isError).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
it('should reject when required field is undefined', async () => {
|
|
263
|
+
const r = await builder.execute(undefined, {
|
|
264
|
+
action: 'process', tenant: 'ok', count: undefined, email: 'a@b.com',
|
|
265
|
+
});
|
|
266
|
+
expect(r.isError).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
it('should handle array at max size', async () => {
|
|
269
|
+
const r = await builder.execute(undefined, {
|
|
270
|
+
action: 'process',
|
|
271
|
+
tenant: 'ok',
|
|
272
|
+
count: 1,
|
|
273
|
+
email: 'a@b.com',
|
|
274
|
+
tags: Array.from({ length: 50 }, (_, i) => `tag-${i}`),
|
|
275
|
+
});
|
|
276
|
+
expect(r.isError).toBeUndefined();
|
|
277
|
+
});
|
|
278
|
+
it('should reject array exceeding max size', async () => {
|
|
279
|
+
const r = await builder.execute(undefined, {
|
|
280
|
+
action: 'process',
|
|
281
|
+
tenant: 'ok',
|
|
282
|
+
count: 1,
|
|
283
|
+
email: 'a@b.com',
|
|
284
|
+
tags: Array.from({ length: 51 }, (_, i) => `tag-${i}`),
|
|
285
|
+
});
|
|
286
|
+
expect(r.isError).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// 4. Prototype Pollution & Injection via Field Names
|
|
291
|
+
// ============================================================================
|
|
292
|
+
describe('QA: Prototype Pollution & Injection', () => {
|
|
293
|
+
it('should not corrupt object prototype via args', async () => {
|
|
294
|
+
const builder = new GroupedToolBuilder('proto_test')
|
|
295
|
+
.action({
|
|
296
|
+
name: 'test',
|
|
297
|
+
schema: z.object({ data: z.string() }),
|
|
298
|
+
handler: async () => success('safe'),
|
|
299
|
+
});
|
|
300
|
+
builder.buildToolDefinition();
|
|
301
|
+
// Attempt prototype pollution
|
|
302
|
+
const maliciousArgs = JSON.parse('{"action":"test","data":"ok","__proto__":{"polluted":true}}');
|
|
303
|
+
const result = await builder.execute(undefined, maliciousArgs);
|
|
304
|
+
expect(result.isError).toBeUndefined();
|
|
305
|
+
// Verify Object.prototype is not polluted
|
|
306
|
+
expect(({}).polluted).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
it('should handle constructor-named field safely', async () => {
|
|
309
|
+
const builder = new GroupedToolBuilder('constructor_test')
|
|
310
|
+
.action({
|
|
311
|
+
name: 'test',
|
|
312
|
+
handler: async () => success('safe'),
|
|
313
|
+
});
|
|
314
|
+
builder.buildToolDefinition();
|
|
315
|
+
const result = await builder.execute(undefined, {
|
|
316
|
+
action: 'test',
|
|
317
|
+
constructor: { prototype: { hacked: true } },
|
|
318
|
+
});
|
|
319
|
+
expect(result.isError).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
it('should handle toString/valueOf override attempts', async () => {
|
|
322
|
+
const builder = new GroupedToolBuilder('override_test')
|
|
323
|
+
.action({
|
|
324
|
+
name: 'test',
|
|
325
|
+
schema: z.object({ value: z.string() }),
|
|
326
|
+
handler: async (_ctx, args) => success(`got: ${args.value}`),
|
|
327
|
+
});
|
|
328
|
+
builder.buildToolDefinition();
|
|
329
|
+
const result = await builder.execute(undefined, {
|
|
330
|
+
action: 'test',
|
|
331
|
+
value: 'normal',
|
|
332
|
+
toString: () => 'hacked',
|
|
333
|
+
valueOf: () => 999,
|
|
334
|
+
});
|
|
335
|
+
expect(result.isError).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// 5. Discriminator Abuse — Every Invalid Type
|
|
340
|
+
// ============================================================================
|
|
341
|
+
describe('QA: Discriminator Abuse', () => {
|
|
342
|
+
let builder;
|
|
343
|
+
beforeAll(() => {
|
|
344
|
+
builder = new GroupedToolBuilder('disc_abuse')
|
|
345
|
+
.action({ name: 'valid', handler: async () => success('ok') });
|
|
346
|
+
builder.buildToolDefinition();
|
|
347
|
+
});
|
|
348
|
+
it('should reject null action', async () => {
|
|
349
|
+
const r = await builder.execute(undefined, { action: null });
|
|
350
|
+
expect(r.isError).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
it('should reject numeric action', async () => {
|
|
353
|
+
const r = await builder.execute(undefined, { action: 42 });
|
|
354
|
+
expect(r.isError).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
it('should reject boolean action', async () => {
|
|
357
|
+
const r = await builder.execute(undefined, { action: true });
|
|
358
|
+
expect(r.isError).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
it('should reject empty string action', async () => {
|
|
361
|
+
const r = await builder.execute(undefined, { action: '' });
|
|
362
|
+
expect(r.isError).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
it('should reject object action', async () => {
|
|
365
|
+
const r = await builder.execute(undefined, { action: { name: 'valid' } });
|
|
366
|
+
expect(r.isError).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
it('should reject array action', async () => {
|
|
369
|
+
const r = await builder.execute(undefined, { action: ['valid'] });
|
|
370
|
+
expect(r.isError).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
it('should reject action with only whitespace', async () => {
|
|
373
|
+
const r = await builder.execute(undefined, { action: ' ' });
|
|
374
|
+
expect(r.isError).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
it('should reject action with SQL injection attempt', async () => {
|
|
377
|
+
const r = await builder.execute(undefined, {
|
|
378
|
+
action: "valid'; DROP TABLE users; --",
|
|
379
|
+
});
|
|
380
|
+
expect(r.isError).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
it('should list available actions on unknown action error', async () => {
|
|
383
|
+
const r = await builder.execute(undefined, { action: 'unknown' });
|
|
384
|
+
expect(r.isError).toBe(true);
|
|
385
|
+
expect(r.content[0].text).toContain('valid');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// 6. Build Idempotency — Cache Integrity
|
|
390
|
+
// ============================================================================
|
|
391
|
+
describe('QA: Build Idempotency', () => {
|
|
392
|
+
it('should return same cached reference on multiple builds', () => {
|
|
393
|
+
const b = new GroupedToolBuilder('idempotent')
|
|
394
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
395
|
+
const first = b.buildToolDefinition();
|
|
396
|
+
const second = b.buildToolDefinition();
|
|
397
|
+
const third = b.buildToolDefinition();
|
|
398
|
+
expect(first).toBe(second);
|
|
399
|
+
expect(second).toBe(third);
|
|
400
|
+
});
|
|
401
|
+
it('should produce structurally identical definitions', () => {
|
|
402
|
+
const b = new GroupedToolBuilder('structural')
|
|
403
|
+
.description('Test tool')
|
|
404
|
+
.commonSchema(z.object({ org: z.string() }))
|
|
405
|
+
.action({
|
|
406
|
+
name: 'get',
|
|
407
|
+
schema: z.object({ id: z.string() }),
|
|
408
|
+
readOnly: true,
|
|
409
|
+
handler: async () => success('ok'),
|
|
410
|
+
});
|
|
411
|
+
const def = b.buildToolDefinition();
|
|
412
|
+
expect(def.name).toBe('structural');
|
|
413
|
+
expect(def.inputSchema.properties).toHaveProperty('action');
|
|
414
|
+
expect(def.inputSchema.properties).toHaveProperty('org');
|
|
415
|
+
expect(def.inputSchema.properties).toHaveProperty('id');
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
// ============================================================================
|
|
419
|
+
// 7. Middleware Ordering — LIFO Wrapping Guarantee
|
|
420
|
+
// ============================================================================
|
|
421
|
+
describe('QA: Middleware Ordering', () => {
|
|
422
|
+
it('should execute middlewares in registration order (left to right)', async () => {
|
|
423
|
+
const order = [];
|
|
424
|
+
const b = new GroupedToolBuilder('mw_order')
|
|
425
|
+
.use(async (_ctx, _args, next) => { order.push(1); return next(); })
|
|
426
|
+
.use(async (_ctx, _args, next) => { order.push(2); return next(); })
|
|
427
|
+
.use(async (_ctx, _args, next) => { order.push(3); return next(); })
|
|
428
|
+
.action({
|
|
429
|
+
name: 'run',
|
|
430
|
+
handler: async () => { order.push(4); return success('done'); },
|
|
431
|
+
});
|
|
432
|
+
b.buildToolDefinition();
|
|
433
|
+
await b.execute(undefined, { action: 'run' });
|
|
434
|
+
expect(order).toEqual([1, 2, 3, 4]);
|
|
435
|
+
});
|
|
436
|
+
it('should short-circuit correctly — later middleware should not run', async () => {
|
|
437
|
+
const order = [];
|
|
438
|
+
const b = new GroupedToolBuilder('mw_short')
|
|
439
|
+
.use(async (_ctx, _args, next) => { order.push('first'); return next(); })
|
|
440
|
+
.use(async () => { order.push('guard'); return error('BLOCKED'); })
|
|
441
|
+
.use(async (_ctx, _args, next) => { order.push('never'); return next(); })
|
|
442
|
+
.action({
|
|
443
|
+
name: 'run',
|
|
444
|
+
handler: async () => { order.push('handler'); return success('done'); },
|
|
445
|
+
});
|
|
446
|
+
b.buildToolDefinition();
|
|
447
|
+
const result = await b.execute(undefined, { action: 'run' });
|
|
448
|
+
expect(order).toEqual(['first', 'guard']);
|
|
449
|
+
expect(result.isError).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
it('middleware should receive validated args, not raw input', async () => {
|
|
452
|
+
let capturedArgs = {};
|
|
453
|
+
const b = new GroupedToolBuilder('mw_validated')
|
|
454
|
+
.commonSchema(z.object({ org: z.string() }))
|
|
455
|
+
.use(async (_ctx, args, next) => {
|
|
456
|
+
capturedArgs = args;
|
|
457
|
+
return next();
|
|
458
|
+
})
|
|
459
|
+
.action({
|
|
460
|
+
name: 'check',
|
|
461
|
+
schema: z.object({ id: z.string() }),
|
|
462
|
+
handler: async () => success('ok'),
|
|
463
|
+
});
|
|
464
|
+
b.buildToolDefinition();
|
|
465
|
+
await b.execute(undefined, {
|
|
466
|
+
action: 'check',
|
|
467
|
+
org: 'acme',
|
|
468
|
+
id: 'x',
|
|
469
|
+
extra_garbage: 'should_be_stripped',
|
|
470
|
+
});
|
|
471
|
+
// Middleware sees stripped args (no extra_garbage)
|
|
472
|
+
expect(capturedArgs).not.toHaveProperty('extra_garbage');
|
|
473
|
+
expect(capturedArgs).toHaveProperty('org', 'acme');
|
|
474
|
+
expect(capturedArgs).toHaveProperty('id', 'x');
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
// ============================================================================
|
|
478
|
+
// 8. Annotation Aggregation Invariants — All Boolean Combos
|
|
479
|
+
// ============================================================================
|
|
480
|
+
describe('QA: Annotation Aggregation', () => {
|
|
481
|
+
it('should report readOnly=true only when ALL actions are readOnly', () => {
|
|
482
|
+
const b = new GroupedToolBuilder('all_read')
|
|
483
|
+
.action({ name: 'list', readOnly: true, handler: async () => success('ok') })
|
|
484
|
+
.action({ name: 'get', readOnly: true, handler: async () => success('ok') });
|
|
485
|
+
const def = b.buildToolDefinition();
|
|
486
|
+
expect(def.annotations.readOnlyHint).toBe(true);
|
|
487
|
+
});
|
|
488
|
+
it('should report readOnly=false when ANY action is not readOnly', () => {
|
|
489
|
+
const b = new GroupedToolBuilder('mixed_read')
|
|
490
|
+
.action({ name: 'list', readOnly: true, handler: async () => success('ok') })
|
|
491
|
+
.action({ name: 'create', handler: async () => success('ok') });
|
|
492
|
+
const def = b.buildToolDefinition();
|
|
493
|
+
expect(def.annotations.readOnlyHint).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
it('should report destructive=true when ANY action is destructive', () => {
|
|
496
|
+
const b = new GroupedToolBuilder('has_destructive')
|
|
497
|
+
.action({ name: 'list', readOnly: true, handler: async () => success('ok') })
|
|
498
|
+
.action({ name: 'delete', destructive: true, handler: async () => success('ok') });
|
|
499
|
+
const def = b.buildToolDefinition();
|
|
500
|
+
expect(def.annotations.destructiveHint).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
it('should report destructive=false when NO action is destructive', () => {
|
|
503
|
+
const b = new GroupedToolBuilder('no_destructive')
|
|
504
|
+
.action({ name: 'list', handler: async () => success('ok') })
|
|
505
|
+
.action({ name: 'get', handler: async () => success('ok') });
|
|
506
|
+
const def = b.buildToolDefinition();
|
|
507
|
+
expect(def.annotations.destructiveHint).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
it('should report idempotent=true only when ALL actions are idempotent', () => {
|
|
510
|
+
const b = new GroupedToolBuilder('all_idempotent')
|
|
511
|
+
.action({ name: 'put', idempotent: true, handler: async () => success('ok') })
|
|
512
|
+
.action({ name: 'delete', idempotent: true, handler: async () => success('ok') });
|
|
513
|
+
const def = b.buildToolDefinition();
|
|
514
|
+
expect(def.annotations.idempotentHint).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
it('should respect explicit annotation override even when actions disagree', () => {
|
|
517
|
+
const b = new GroupedToolBuilder('override')
|
|
518
|
+
.annotations({ readOnlyHint: true }) // explicit override
|
|
519
|
+
.action({ name: 'create', handler: async () => success('ok') }) // not read-only
|
|
520
|
+
.action({ name: 'delete', destructive: true, handler: async () => success('ok') });
|
|
521
|
+
const def = b.buildToolDefinition();
|
|
522
|
+
// Explicit override wins
|
|
523
|
+
expect(def.annotations.readOnlyHint).toBe(true);
|
|
524
|
+
// destructiveHint still auto-aggregated since not explicitly set
|
|
525
|
+
expect(def.annotations.destructiveHint).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
// ============================================================================
|
|
529
|
+
// 9. Description Generation Contract
|
|
530
|
+
// ============================================================================
|
|
531
|
+
describe('QA: Description Generation', () => {
|
|
532
|
+
it('flat mode description should list action names', () => {
|
|
533
|
+
const b = new GroupedToolBuilder('desc_flat')
|
|
534
|
+
.description('My API')
|
|
535
|
+
.action({ name: 'list', handler: async () => success('ok') })
|
|
536
|
+
.action({ name: 'create', handler: async () => success('ok') })
|
|
537
|
+
.action({ name: 'delete', handler: async () => success('ok') });
|
|
538
|
+
const def = b.buildToolDefinition();
|
|
539
|
+
expect(def.description).toContain('Actions:');
|
|
540
|
+
expect(def.description).toContain('list');
|
|
541
|
+
expect(def.description).toContain('create');
|
|
542
|
+
expect(def.description).toContain('delete');
|
|
543
|
+
});
|
|
544
|
+
it('grouped mode description should list modules', () => {
|
|
545
|
+
const b = new GroupedToolBuilder('desc_grouped')
|
|
546
|
+
.description('Enterprise API')
|
|
547
|
+
.group('users', 'User mgmt', g => g
|
|
548
|
+
.action({ name: 'list', handler: async () => success('ok') })
|
|
549
|
+
.action({ name: 'create', handler: async () => success('ok') }))
|
|
550
|
+
.group('billing', 'Billing', g => g
|
|
551
|
+
.action({ name: 'charge', handler: async () => success('ok') }));
|
|
552
|
+
const def = b.buildToolDefinition();
|
|
553
|
+
expect(def.description).toContain('Modules:');
|
|
554
|
+
expect(def.description).toContain('users');
|
|
555
|
+
expect(def.description).toContain('billing');
|
|
556
|
+
});
|
|
557
|
+
it('workflow lines should show required fields and descriptions', () => {
|
|
558
|
+
const b = new GroupedToolBuilder('desc_workflow')
|
|
559
|
+
.action({
|
|
560
|
+
name: 'create',
|
|
561
|
+
description: 'Create a new record',
|
|
562
|
+
schema: z.object({ name: z.string(), priority: z.number() }),
|
|
563
|
+
handler: async () => success('ok'),
|
|
564
|
+
});
|
|
565
|
+
const def = b.buildToolDefinition();
|
|
566
|
+
expect(def.description).toContain('Workflow:');
|
|
567
|
+
expect(def.description).toContain('Create a new record');
|
|
568
|
+
expect(def.description).toContain('name');
|
|
569
|
+
expect(def.description).toContain('priority');
|
|
570
|
+
});
|
|
571
|
+
it('destructive actions should show ⚠️ DESTRUCTIVE in workflow', () => {
|
|
572
|
+
const b = new GroupedToolBuilder('desc_destructive')
|
|
573
|
+
.action({
|
|
574
|
+
name: 'nuke',
|
|
575
|
+
description: 'Delete everything',
|
|
576
|
+
destructive: true,
|
|
577
|
+
handler: async () => success('ok'),
|
|
578
|
+
});
|
|
579
|
+
const def = b.buildToolDefinition();
|
|
580
|
+
expect(def.description).toContain('⚠️ DESTRUCTIVE');
|
|
581
|
+
});
|
|
582
|
+
it('should use tool name as fallback when no description set', () => {
|
|
583
|
+
const b = new GroupedToolBuilder('fallback_name')
|
|
584
|
+
.action({ name: 'ping', handler: async () => success('ok') });
|
|
585
|
+
const def = b.buildToolDefinition();
|
|
586
|
+
expect(def.description).toContain('fallback_name');
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
// ============================================================================
|
|
590
|
+
// 10. Schema Field Annotation Accuracy
|
|
591
|
+
// ============================================================================
|
|
592
|
+
describe('QA: Schema Field Annotations', () => {
|
|
593
|
+
it('common schema required fields should be marked "always required"', () => {
|
|
594
|
+
const b = new GroupedToolBuilder('field_annot')
|
|
595
|
+
.commonSchema(z.object({
|
|
596
|
+
workspace_id: z.string().describe('The workspace'),
|
|
597
|
+
}))
|
|
598
|
+
.action({
|
|
599
|
+
name: 'list',
|
|
600
|
+
handler: async () => success('ok'),
|
|
601
|
+
});
|
|
602
|
+
const def = b.buildToolDefinition();
|
|
603
|
+
const wsField = def.inputSchema.properties.workspace_id;
|
|
604
|
+
expect(wsField.description).toContain('always required');
|
|
605
|
+
});
|
|
606
|
+
it('action-specific required fields should show "Required for" annotation', () => {
|
|
607
|
+
const b = new GroupedToolBuilder('field_req')
|
|
608
|
+
.action({
|
|
609
|
+
name: 'create',
|
|
610
|
+
schema: z.object({
|
|
611
|
+
name: z.string().describe('Record name'),
|
|
612
|
+
}),
|
|
613
|
+
handler: async () => success('ok'),
|
|
614
|
+
})
|
|
615
|
+
.action({
|
|
616
|
+
name: 'list',
|
|
617
|
+
handler: async () => success('ok'),
|
|
618
|
+
});
|
|
619
|
+
const def = b.buildToolDefinition();
|
|
620
|
+
const nameField = def.inputSchema.properties.name;
|
|
621
|
+
expect(nameField.description).toContain('Required for');
|
|
622
|
+
expect(nameField.description).toContain('create');
|
|
623
|
+
});
|
|
624
|
+
it('optional fields should show "For" annotation', () => {
|
|
625
|
+
const b = new GroupedToolBuilder('field_opt')
|
|
626
|
+
.action({
|
|
627
|
+
name: 'search',
|
|
628
|
+
schema: z.object({
|
|
629
|
+
filter: z.string().optional().describe('Search filter'),
|
|
630
|
+
}),
|
|
631
|
+
handler: async () => success('ok'),
|
|
632
|
+
});
|
|
633
|
+
const def = b.buildToolDefinition();
|
|
634
|
+
const filterField = def.inputSchema.properties.filter;
|
|
635
|
+
expect(filterField.description).toContain('For');
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
// ============================================================================
|
|
639
|
+
// 11. Custom Discriminator
|
|
640
|
+
// ============================================================================
|
|
641
|
+
describe('QA: Custom Discriminator', () => {
|
|
642
|
+
it('should use custom discriminator field name', async () => {
|
|
643
|
+
const b = new GroupedToolBuilder('custom_disc')
|
|
644
|
+
.discriminator('operation')
|
|
645
|
+
.action({ name: 'ping', handler: async () => success('pong') });
|
|
646
|
+
const def = b.buildToolDefinition();
|
|
647
|
+
expect(def.inputSchema.properties).toHaveProperty('operation');
|
|
648
|
+
expect(def.inputSchema.required).toContain('operation');
|
|
649
|
+
// Execute with custom discriminator
|
|
650
|
+
const result = await b.execute(undefined, { operation: 'ping' });
|
|
651
|
+
expect(result.isError).toBeUndefined();
|
|
652
|
+
expect(result.content[0].text).toBe('pong');
|
|
653
|
+
});
|
|
654
|
+
it('should reject when custom discriminator is missing', async () => {
|
|
655
|
+
const b = new GroupedToolBuilder('disc_missing')
|
|
656
|
+
.discriminator('cmd')
|
|
657
|
+
.action({ name: 'run', handler: async () => success('ok') });
|
|
658
|
+
b.buildToolDefinition();
|
|
659
|
+
const result = await b.execute(undefined, { action: 'run' }); // 'action' not 'cmd'
|
|
660
|
+
expect(result.isError).toBe(true);
|
|
661
|
+
expect(result.content[0].text).toContain('cmd');
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
// ============================================================================
|
|
665
|
+
// 12. ToolRegistry Contract
|
|
666
|
+
// ============================================================================
|
|
667
|
+
describe('QA: ToolRegistry Contract', () => {
|
|
668
|
+
it('should throw on duplicate registration', () => {
|
|
669
|
+
const registry = new ToolRegistry();
|
|
670
|
+
const b1 = new GroupedToolBuilder('dupe')
|
|
671
|
+
.action({ name: 'a', handler: async () => success('ok') });
|
|
672
|
+
const b2 = new GroupedToolBuilder('dupe')
|
|
673
|
+
.action({ name: 'b', handler: async () => success('ok') });
|
|
674
|
+
registry.register(b1);
|
|
675
|
+
expect(() => registry.register(b2)).toThrow(/already registered/i);
|
|
676
|
+
});
|
|
677
|
+
it('should route to correct tool in multi-tool registry', async () => {
|
|
678
|
+
const registry = new ToolRegistry();
|
|
679
|
+
registry.registerAll(new GroupedToolBuilder('tool_a')
|
|
680
|
+
.action({ name: 'ping', handler: async () => success('A:pong') }), new GroupedToolBuilder('tool_b')
|
|
681
|
+
.action({ name: 'ping', handler: async () => success('B:pong') }));
|
|
682
|
+
const rA = await registry.routeCall(undefined, 'tool_a', { action: 'ping' });
|
|
683
|
+
expect(rA.content[0].text).toBe('A:pong');
|
|
684
|
+
const rB = await registry.routeCall(undefined, 'tool_b', { action: 'ping' });
|
|
685
|
+
expect(rB.content[0].text).toBe('B:pong');
|
|
686
|
+
});
|
|
687
|
+
it('should return error for unknown tool (not throw)', async () => {
|
|
688
|
+
const registry = new ToolRegistry();
|
|
689
|
+
registry.register(new GroupedToolBuilder('only_one')
|
|
690
|
+
.action({ name: 'a', handler: async () => success('ok') }));
|
|
691
|
+
const result = await registry.routeCall(undefined, 'ghost', { action: 'a' });
|
|
692
|
+
expect(result.isError).toBe(true);
|
|
693
|
+
expect(result.content[0].text).toContain('Unknown tool');
|
|
694
|
+
expect(result.content[0].text).toContain('only_one');
|
|
695
|
+
});
|
|
696
|
+
it('registerAll should register multiple builders at once', () => {
|
|
697
|
+
const registry = new ToolRegistry();
|
|
698
|
+
registry.registerAll(new GroupedToolBuilder('batch_1')
|
|
699
|
+
.action({ name: 'a', handler: async () => success('ok') }), new GroupedToolBuilder('batch_2')
|
|
700
|
+
.action({ name: 'a', handler: async () => success('ok') }), new GroupedToolBuilder('batch_3')
|
|
701
|
+
.action({ name: 'a', handler: async () => success('ok') }));
|
|
702
|
+
expect(registry.size).toBe(3);
|
|
703
|
+
});
|
|
704
|
+
it('getTools with empty tags array should return all tools', () => {
|
|
705
|
+
const registry = new ToolRegistry();
|
|
706
|
+
registry.register(new GroupedToolBuilder('no_tags')
|
|
707
|
+
.action({ name: 'a', handler: async () => success('ok') }));
|
|
708
|
+
const tools = registry.getTools({ tags: [] });
|
|
709
|
+
expect(tools).toHaveLength(1);
|
|
710
|
+
});
|
|
711
|
+
it('getTools with empty exclude array should return all tools', () => {
|
|
712
|
+
const registry = new ToolRegistry();
|
|
713
|
+
registry.register(new GroupedToolBuilder('no_exclude')
|
|
714
|
+
.tags('x')
|
|
715
|
+
.action({ name: 'a', handler: async () => success('ok') }));
|
|
716
|
+
const tools = registry.getTools({ exclude: [] });
|
|
717
|
+
expect(tools).toHaveLength(1);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
// ============================================================================
|
|
721
|
+
// 13. ResponseHelper Contract
|
|
722
|
+
// ============================================================================
|
|
723
|
+
describe('QA: ResponseHelper Contract', () => {
|
|
724
|
+
it('success() should produce valid MCP response', () => {
|
|
725
|
+
const r = success('hello');
|
|
726
|
+
expect(r.content).toEqual([{ type: 'text', text: 'hello' }]);
|
|
727
|
+
expect(r.isError).toBeUndefined();
|
|
728
|
+
});
|
|
729
|
+
it('error() should produce valid MCP error response', () => {
|
|
730
|
+
const r = error('bad');
|
|
731
|
+
expect(r.content).toEqual([{ type: 'text', text: 'bad' }]);
|
|
732
|
+
expect(r.isError).toBe(true);
|
|
733
|
+
});
|
|
734
|
+
it('required() should produce validation error with field name', () => {
|
|
735
|
+
const r = required('email');
|
|
736
|
+
expect(r.content[0].text).toContain('email');
|
|
737
|
+
expect(r.content[0].text).toContain('required');
|
|
738
|
+
expect(r.isError).toBe(true);
|
|
739
|
+
});
|
|
740
|
+
it('all helpers should return frozen-safe responses (no mutation risk)', () => {
|
|
741
|
+
const s = success('x');
|
|
742
|
+
const e = error('x');
|
|
743
|
+
const r = required('x');
|
|
744
|
+
// Responses should be plain objects, not class instances
|
|
745
|
+
expect(s.constructor).toBe(Object);
|
|
746
|
+
expect(e.constructor).toBe(Object);
|
|
747
|
+
expect(r.constructor).toBe(Object);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
// ============================================================================
|
|
751
|
+
// 14. Group Mode Invariants
|
|
752
|
+
// ============================================================================
|
|
753
|
+
describe('QA: Group Mode Invariants', () => {
|
|
754
|
+
it('should reject dot in group name', () => {
|
|
755
|
+
const b = new GroupedToolBuilder('dot_group');
|
|
756
|
+
expect(() => b.group('users.admin', 'bad', g => g
|
|
757
|
+
.action({ name: 'list', handler: async () => success('ok') }))).toThrow(/dots/i);
|
|
758
|
+
});
|
|
759
|
+
it('should reject dot in action name within group', () => {
|
|
760
|
+
const b = new GroupedToolBuilder('dot_action');
|
|
761
|
+
expect(() => b.group('users', 'Users', g => g
|
|
762
|
+
.action({ name: 'list.all', handler: async () => success('ok') }))).toThrow(/dots/i);
|
|
763
|
+
});
|
|
764
|
+
it('should reject mixing .action() then .group()', () => {
|
|
765
|
+
const b = new GroupedToolBuilder('mix_ag')
|
|
766
|
+
.action({ name: 'flat', handler: async () => success('ok') });
|
|
767
|
+
expect(() => b.group('grp', 'Grp', g => g
|
|
768
|
+
.action({ name: 'a', handler: async () => success('ok') }))).toThrow(/Cannot use/i);
|
|
769
|
+
});
|
|
770
|
+
it('should reject mixing .group() then .action()', () => {
|
|
771
|
+
const b = new GroupedToolBuilder('mix_ga')
|
|
772
|
+
.group('grp', 'Grp', g => g
|
|
773
|
+
.action({ name: 'a', handler: async () => success('ok') }));
|
|
774
|
+
expect(() => b.action({ name: 'flat', handler: async () => success('ok') }))
|
|
775
|
+
.toThrow(/Cannot use/i);
|
|
776
|
+
});
|
|
777
|
+
it('grouped mode should produce compound action keys', () => {
|
|
778
|
+
const b = new GroupedToolBuilder('compound')
|
|
779
|
+
.group('users', 'Users', g => g
|
|
780
|
+
.action({ name: 'list', handler: async () => success('ok') })
|
|
781
|
+
.action({ name: 'create', handler: async () => success('ok') }));
|
|
782
|
+
const names = b.getActionNames();
|
|
783
|
+
// Before build, action names aren't populated, so build first
|
|
784
|
+
b.buildToolDefinition();
|
|
785
|
+
const postBuildNames = b.getActionNames();
|
|
786
|
+
expect(postBuildNames).toContain('users.list');
|
|
787
|
+
expect(postBuildNames).toContain('users.create');
|
|
788
|
+
});
|
|
789
|
+
it('should route correctly to grouped action via compound key', async () => {
|
|
790
|
+
const b = new GroupedToolBuilder('route_compound')
|
|
791
|
+
.group('billing', 'Billing', g => g
|
|
792
|
+
.action({
|
|
793
|
+
name: 'charge',
|
|
794
|
+
schema: z.object({ amount: z.number() }),
|
|
795
|
+
handler: async (_ctx, args) => success(`charged ${args.amount}`),
|
|
796
|
+
}));
|
|
797
|
+
b.buildToolDefinition();
|
|
798
|
+
const r = await b.execute(undefined, {
|
|
799
|
+
action: 'billing.charge',
|
|
800
|
+
amount: 42,
|
|
801
|
+
});
|
|
802
|
+
expect(r.isError).toBeUndefined();
|
|
803
|
+
expect(r.content[0].text).toContain('charged 42');
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
// ============================================================================
|
|
807
|
+
// 15. Extreme Edge Cases — The Outer Boundaries
|
|
808
|
+
// ============================================================================
|
|
809
|
+
describe('QA: Extreme Edge Cases', () => {
|
|
810
|
+
it('should handle single-action tool (simplest possible case)', async () => {
|
|
811
|
+
const b = new GroupedToolBuilder('minimal')
|
|
812
|
+
.action({ name: 'do', handler: async () => success('done') });
|
|
813
|
+
b.buildToolDefinition();
|
|
814
|
+
const r = await b.execute(undefined, { action: 'do' });
|
|
815
|
+
expect(r.isError).toBeUndefined();
|
|
816
|
+
});
|
|
817
|
+
it('should handle tool with many actions (50+)', () => {
|
|
818
|
+
const b = new GroupedToolBuilder('fifty_actions');
|
|
819
|
+
for (let i = 0; i < 50; i++) {
|
|
820
|
+
b.action({
|
|
821
|
+
name: `action_${i}`,
|
|
822
|
+
handler: async () => success(`result_${i}`),
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const def = b.buildToolDefinition();
|
|
826
|
+
const actionProp = def.inputSchema.properties.action;
|
|
827
|
+
expect(actionProp.enum).toHaveLength(50);
|
|
828
|
+
});
|
|
829
|
+
it('should handle very long tool name', () => {
|
|
830
|
+
const longName = 'a'.repeat(200);
|
|
831
|
+
const b = new GroupedToolBuilder(longName)
|
|
832
|
+
.action({ name: 'x', handler: async () => success('ok') });
|
|
833
|
+
const def = b.buildToolDefinition();
|
|
834
|
+
expect(def.name).toBe(longName);
|
|
835
|
+
});
|
|
836
|
+
it('should handle tool with no schema at all (no validation)', async () => {
|
|
837
|
+
const b = new GroupedToolBuilder('no_schema')
|
|
838
|
+
.action({
|
|
839
|
+
name: 'run',
|
|
840
|
+
handler: async (_ctx, args) => success(`args: ${JSON.stringify(args)}`),
|
|
841
|
+
});
|
|
842
|
+
b.buildToolDefinition();
|
|
843
|
+
// Any garbage args should pass through (no validation)
|
|
844
|
+
const r = await b.execute(undefined, {
|
|
845
|
+
action: 'run',
|
|
846
|
+
anything: true,
|
|
847
|
+
nested: { deep: 'value' },
|
|
848
|
+
});
|
|
849
|
+
expect(r.isError).toBeUndefined();
|
|
850
|
+
});
|
|
851
|
+
it('should handle concurrent execute calls on same builder', async () => {
|
|
852
|
+
let callCount = 0;
|
|
853
|
+
const b = new GroupedToolBuilder('concurrent')
|
|
854
|
+
.action({
|
|
855
|
+
name: 'count',
|
|
856
|
+
handler: async () => {
|
|
857
|
+
callCount++;
|
|
858
|
+
// Simulate async work
|
|
859
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
860
|
+
return success(`call ${callCount}`);
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
b.buildToolDefinition();
|
|
864
|
+
// Fire 10 concurrent calls
|
|
865
|
+
const promises = Array.from({ length: 10 }, () => b.execute(undefined, { action: 'count' }));
|
|
866
|
+
const results = await Promise.all(promises);
|
|
867
|
+
// All should succeed
|
|
868
|
+
for (const r of results) {
|
|
869
|
+
expect(r.isError).toBeUndefined();
|
|
870
|
+
}
|
|
871
|
+
expect(callCount).toBe(10);
|
|
872
|
+
});
|
|
873
|
+
it('should handle handler that returns success with empty string', async () => {
|
|
874
|
+
const b = new GroupedToolBuilder('empty_success')
|
|
875
|
+
.action({ name: 'empty', handler: async () => success('') });
|
|
876
|
+
b.buildToolDefinition();
|
|
877
|
+
const r = await b.execute(undefined, { action: 'empty' });
|
|
878
|
+
expect(r.isError).toBeUndefined();
|
|
879
|
+
expect(r.content[0].text).toBe('');
|
|
880
|
+
});
|
|
881
|
+
it('should handle action names that are JavaScript keywords', async () => {
|
|
882
|
+
const b = new GroupedToolBuilder('js_keywords')
|
|
883
|
+
.action({ name: 'delete', handler: async () => success('deleted') })
|
|
884
|
+
.action({ name: 'return', handler: async () => success('returned') })
|
|
885
|
+
.action({ name: 'class', handler: async () => success('classed') })
|
|
886
|
+
.action({ name: 'export', handler: async () => success('exported') });
|
|
887
|
+
b.buildToolDefinition();
|
|
888
|
+
const r = await b.execute(undefined, { action: 'delete' });
|
|
889
|
+
expect(r.isError).toBeUndefined();
|
|
890
|
+
const r2 = await b.execute(undefined, { action: 'return' });
|
|
891
|
+
expect(r2.isError).toBeUndefined();
|
|
892
|
+
});
|
|
893
|
+
it('getTags should return a copy, not a reference', () => {
|
|
894
|
+
const b = new GroupedToolBuilder('tag_copy')
|
|
895
|
+
.tags('a', 'b', 'c')
|
|
896
|
+
.action({ name: 'x', handler: async () => success('ok') });
|
|
897
|
+
const tags1 = b.getTags();
|
|
898
|
+
const tags2 = b.getTags();
|
|
899
|
+
expect(tags1).toEqual(tags2);
|
|
900
|
+
expect(tags1).not.toBe(tags2); // Different array references
|
|
901
|
+
// Mutation of returned array should not affect builder
|
|
902
|
+
tags1.push('hacked');
|
|
903
|
+
expect(b.getTags()).not.toContain('hacked');
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
//# sourceMappingURL=AdversarialQA.test.js.map
|