@aura-labs-ai/scout 0.2.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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * MCP Client Tests
3
+ *
4
+ * Tests for Model Context Protocol client implementation.
5
+ *
6
+ * Run:
7
+ * node --test src/tests/mcp-client.test.js
8
+ */
9
+
10
+ import { test, describe } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { MCPClient } from '../mcp/client.js';
13
+
14
+ // =============================================================================
15
+ // Client Initialization Tests
16
+ // =============================================================================
17
+
18
+ describe('MCP Client Initialization', () => {
19
+ test('creates client with default config', () => {
20
+ const client = new MCPClient();
21
+
22
+ assert.ok(client);
23
+ assert.strictEqual(client.isConnected, false);
24
+ });
25
+
26
+ test('creates client with custom config', () => {
27
+ const client = new MCPClient({
28
+ clientInfo: {
29
+ name: 'test-client',
30
+ version: '2.0.0',
31
+ },
32
+ });
33
+
34
+ assert.ok(client);
35
+ assert.strictEqual(client.clientInfo.name, 'test-client');
36
+ assert.strictEqual(client.clientInfo.version, '2.0.0');
37
+ });
38
+
39
+ test('uses default client info when not provided', () => {
40
+ const client = new MCPClient({});
41
+
42
+ assert.strictEqual(client.clientInfo.name, 'aura-scout');
43
+ assert.strictEqual(client.clientInfo.version, '1.0.0');
44
+ });
45
+ });
46
+
47
+ // =============================================================================
48
+ // Connection State Tests
49
+ // =============================================================================
50
+
51
+ describe('MCP Client Connection State', () => {
52
+ test('starts disconnected', () => {
53
+ const client = new MCPClient();
54
+ assert.strictEqual(client.isConnected, false);
55
+ });
56
+
57
+ test('returns empty tools list when not connected', async () => {
58
+ const client = new MCPClient();
59
+ const tools = await client.listAllTools();
60
+
61
+ assert.deepStrictEqual(tools, []);
62
+ });
63
+
64
+ test('returns empty context when not connected', async () => {
65
+ const client = new MCPClient();
66
+ const context = await client.aggregateContext({ query: 'test' });
67
+
68
+ assert.deepStrictEqual(context.tools, []);
69
+ assert.deepStrictEqual(context.resources, []);
70
+ });
71
+ });
72
+
73
+ // =============================================================================
74
+ // Event Emitter Tests
75
+ // =============================================================================
76
+
77
+ describe('MCP Client Events', () => {
78
+ test('emits events as EventEmitter', () => {
79
+ const client = new MCPClient();
80
+ let eventReceived = false;
81
+
82
+ client.on('test-event', () => {
83
+ eventReceived = true;
84
+ });
85
+
86
+ client.emit('test-event');
87
+ assert.strictEqual(eventReceived, true);
88
+ });
89
+
90
+ test('supports multiple event listeners', () => {
91
+ const client = new MCPClient();
92
+ const calls = [];
93
+
94
+ client.on('multi-event', () => calls.push(1));
95
+ client.on('multi-event', () => calls.push(2));
96
+
97
+ client.emit('multi-event');
98
+ assert.deepStrictEqual(calls, [1, 2]);
99
+ });
100
+ });
101
+
102
+ // =============================================================================
103
+ // Tool Listing Tests
104
+ // =============================================================================
105
+
106
+ describe('MCP Client Tool Listing', () => {
107
+ test('listAllTools returns array', async () => {
108
+ const client = new MCPClient();
109
+ const tools = await client.listAllTools();
110
+
111
+ assert.ok(Array.isArray(tools));
112
+ });
113
+
114
+ test('listAllTools returns empty array with no servers', async () => {
115
+ const client = new MCPClient();
116
+ const tools = await client.listAllTools();
117
+
118
+ assert.strictEqual(tools.length, 0);
119
+ });
120
+ });
121
+
122
+ // =============================================================================
123
+ // Context Aggregation Tests
124
+ // =============================================================================
125
+
126
+ describe('MCP Client Context Aggregation', () => {
127
+ test('aggregateContext returns context structure', async () => {
128
+ const client = new MCPClient();
129
+ const context = await client.aggregateContext({});
130
+
131
+ assert.ok('tools' in context);
132
+ assert.ok('resources' in context);
133
+ assert.ok('data' in context);
134
+ });
135
+
136
+ test('aggregateContext accepts query parameter', async () => {
137
+ const client = new MCPClient();
138
+ const context = await client.aggregateContext({
139
+ query: 'test query',
140
+ includeResources: true,
141
+ });
142
+
143
+ // Should return empty context (no servers connected)
144
+ assert.deepStrictEqual(context.tools, []);
145
+ assert.deepStrictEqual(context.resources, []);
146
+ });
147
+ });
148
+
149
+ // =============================================================================
150
+ // Tool Call Tests (Mock/Offline)
151
+ // =============================================================================
152
+
153
+ describe('MCP Client Tool Calls', () => {
154
+ test('callTool throws when server not found', async () => {
155
+ const client = new MCPClient();
156
+
157
+ await assert.rejects(
158
+ async () => {
159
+ await client.callTool('unknown-server', 'test-tool', {});
160
+ },
161
+ { message: /not connected/i }
162
+ );
163
+ });
164
+ });
165
+
166
+ // =============================================================================
167
+ // Server Management Tests
168
+ // =============================================================================
169
+
170
+ describe('MCP Client Server Management', () => {
171
+ test('disconnect returns immediately when not connected', async () => {
172
+ const client = new MCPClient();
173
+
174
+ // Should not throw
175
+ await client.disconnect('some-server');
176
+ assert.strictEqual(client.isConnected, false);
177
+ });
178
+
179
+ test('disconnectAll works with no servers', async () => {
180
+ const client = new MCPClient();
181
+
182
+ // Should not throw
183
+ await client.disconnectAll();
184
+ assert.strictEqual(client.isConnected, false);
185
+ });
186
+ });
187
+
188
+ // =============================================================================
189
+ // Protocol Version Tests
190
+ // =============================================================================
191
+
192
+ describe('MCP Client Protocol', () => {
193
+ test('uses correct protocol version', () => {
194
+ const client = new MCPClient();
195
+ assert.strictEqual(client.protocolVersion, '2024-11-05');
196
+ });
197
+
198
+ test('supports required capabilities', () => {
199
+ const client = new MCPClient();
200
+ const capabilities = client.capabilities;
201
+
202
+ assert.ok(capabilities.tools);
203
+ assert.ok(capabilities.resources);
204
+ assert.ok(capabilities.prompts);
205
+ assert.ok(capabilities.sampling);
206
+ });
207
+ });
208
+
209
+ // =============================================================================
210
+ // Error Handling Tests
211
+ // =============================================================================
212
+
213
+ describe('MCP Client Error Handling', () => {
214
+ test('handles connection errors gracefully', async () => {
215
+ const client = new MCPClient();
216
+
217
+ // Attempting to connect to invalid URL should reject
218
+ await assert.rejects(
219
+ async () => {
220
+ await client.connect({ uri: 'https://invalid.example.com/mcp', name: 'invalid' });
221
+ }
222
+ );
223
+ });
224
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Scout SDK ping() unit tests
3
+ *
4
+ * Tests the read-only healthcheck method that wraps Core's GET /health/ready.
5
+ * Verifies: response mapping (ok/degraded/error), no auth headers,
6
+ * activity event recording, timeout handling, and independence from ready().
7
+ */
8
+
9
+ import { describe, it, beforeEach, afterEach, mock } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createScout } from '../index.js';
12
+
13
+ // --- Test helpers ---
14
+
15
+ /**
16
+ * Create a mock fetch that returns the given response
17
+ */
18
+ function mockFetch(status, body, { delay = 0, shouldThrow = false, throwError = null } = {}) {
19
+ return mock.fn(async (url, opts) => {
20
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay));
21
+ if (shouldThrow) throw throwError || new TypeError('fetch failed');
22
+ return {
23
+ ok: status >= 200 && status < 300,
24
+ status,
25
+ json: async () => body,
26
+ };
27
+ });
28
+ }
29
+
30
+ const HEALTHY_RESPONSE = {
31
+ status: 'ready',
32
+ checks: {
33
+ database: {
34
+ status: 'healthy',
35
+ latency_ms: 5,
36
+ pool: { total: 10, idle: 8, waiting: 0 },
37
+ },
38
+ redis: { status: 'unconfigured' },
39
+ },
40
+ timestamp: '2026-03-11T14:30:00.000Z',
41
+ };
42
+
43
+ const DEGRADED_RESPONSE = {
44
+ status: 'not_ready',
45
+ checks: {
46
+ database: {
47
+ status: 'unhealthy',
48
+ latency_ms: 100,
49
+ error: 'Database check failed',
50
+ },
51
+ redis: { status: 'healthy', latency_ms: 3 },
52
+ },
53
+ timestamp: '2026-03-11T14:30:00.000Z',
54
+ };
55
+
56
+ describe('Scout ping()', () => {
57
+ let originalFetch;
58
+
59
+ beforeEach(() => {
60
+ originalFetch = globalThis.fetch;
61
+ });
62
+
63
+ afterEach(() => {
64
+ globalThis.fetch = originalFetch;
65
+ });
66
+
67
+ describe('Core healthy (200)', () => {
68
+ it('returns status ok with core checks', async () => {
69
+ globalThis.fetch = mockFetch(200, HEALTHY_RESPONSE);
70
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
71
+
72
+ const result = await scout.ping();
73
+
74
+ assert.equal(result.status, 'ok');
75
+ assert.equal(result.core.status, 'ready');
76
+ assert.equal(result.core.checks.database.status, 'healthy');
77
+ assert.equal(result.core.checks.database.pool.total, 10);
78
+ assert.equal(result.core.checks.redis.status, 'unconfigured');
79
+ assert.equal(typeof result.latency_ms, 'number');
80
+ assert.ok(result.latency_ms >= 0);
81
+ assert.equal(typeof result.timestamp, 'string');
82
+ // Verify ISO 8601 format
83
+ assert.ok(!isNaN(Date.parse(result.timestamp)));
84
+ });
85
+
86
+ it('records PING_SUCCESS activity event', async () => {
87
+ globalThis.fetch = mockFetch(200, HEALTHY_RESPONSE);
88
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
89
+
90
+ await scout.ping();
91
+
92
+ const events = scout.activity.getEvents({ type: 'ping.success' });
93
+ assert.equal(events.length, 1);
94
+ assert.equal(events[0].metadata.coreStatus, 'ready');
95
+ assert.equal(typeof events[0].metadata.latency_ms, 'number');
96
+ });
97
+
98
+ it('increments ping summary counters', async () => {
99
+ globalThis.fetch = mockFetch(200, HEALTHY_RESPONSE);
100
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
101
+
102
+ await scout.ping();
103
+ await scout.ping();
104
+
105
+ const summary = scout.activity.getSummary();
106
+ assert.equal(summary.ping.total, 2);
107
+ assert.equal(summary.ping.successful, 2);
108
+ assert.equal(summary.ping.failed, 0);
109
+ });
110
+ });
111
+
112
+ describe('Core degraded (503)', () => {
113
+ it('returns status degraded with unhealthy checks', async () => {
114
+ globalThis.fetch = mockFetch(503, DEGRADED_RESPONSE);
115
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
116
+
117
+ const result = await scout.ping();
118
+
119
+ assert.equal(result.status, 'degraded');
120
+ assert.equal(result.core.status, 'not_ready');
121
+ assert.equal(result.core.checks.database.status, 'unhealthy');
122
+ assert.equal(result.core.checks.database.error, 'Database check failed');
123
+ });
124
+
125
+ it('records PING_SUCCESS event (Core responded, just unhealthy)', async () => {
126
+ globalThis.fetch = mockFetch(503, DEGRADED_RESPONSE);
127
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
128
+
129
+ await scout.ping();
130
+
131
+ const events = scout.activity.getEvents({ type: 'ping.success' });
132
+ assert.equal(events.length, 1);
133
+ assert.equal(events[0].metadata.coreStatus, 'not_ready');
134
+ });
135
+ });
136
+
137
+ describe('Core unreachable', () => {
138
+ it('returns status error with CORE_UNREACHABLE code', async () => {
139
+ globalThis.fetch = mockFetch(0, null, {
140
+ shouldThrow: true,
141
+ throwError: new TypeError('fetch failed'),
142
+ });
143
+ const scout = createScout({ coreUrl: 'http://localhost:9999' });
144
+
145
+ const result = await scout.ping();
146
+
147
+ assert.equal(result.status, 'error');
148
+ assert.equal(result.code, 'CORE_UNREACHABLE');
149
+ assert.ok(result.message.includes('http://localhost:9999'));
150
+ assert.equal(typeof result.latency_ms, 'number');
151
+ });
152
+
153
+ it('records PING_FAILED activity event', async () => {
154
+ globalThis.fetch = mockFetch(0, null, {
155
+ shouldThrow: true,
156
+ throwError: new TypeError('fetch failed'),
157
+ });
158
+ const scout = createScout({ coreUrl: 'http://localhost:9999' });
159
+
160
+ await scout.ping();
161
+
162
+ const events = scout.activity.getEvents({ type: 'ping.failed' });
163
+ assert.equal(events.length, 1);
164
+ assert.equal(events[0].metadata.code, 'CORE_UNREACHABLE');
165
+ assert.equal(typeof events[0].metadata.error, 'string');
166
+ });
167
+
168
+ it('increments failed ping counter', async () => {
169
+ globalThis.fetch = mockFetch(0, null, { shouldThrow: true });
170
+ const scout = createScout({ coreUrl: 'http://localhost:9999' });
171
+
172
+ await scout.ping();
173
+
174
+ const summary = scout.activity.getSummary();
175
+ assert.equal(summary.ping.total, 1);
176
+ assert.equal(summary.ping.successful, 0);
177
+ assert.equal(summary.ping.failed, 1);
178
+ });
179
+ });
180
+
181
+ describe('Timeout', () => {
182
+ it('returns status error with CORE_TIMEOUT code on AbortError', async () => {
183
+ const abortError = new DOMException('The operation was aborted', 'AbortError');
184
+ globalThis.fetch = mockFetch(0, null, {
185
+ shouldThrow: true,
186
+ throwError: abortError,
187
+ });
188
+ const scout = createScout({ coreUrl: 'http://localhost:3000', timeout: 100 });
189
+
190
+ const result = await scout.ping();
191
+
192
+ assert.equal(result.status, 'error');
193
+ assert.equal(result.code, 'CORE_TIMEOUT');
194
+ assert.ok(result.message.includes('http://localhost:3000'));
195
+ });
196
+
197
+ it('returns CORE_TIMEOUT on TimeoutError', async () => {
198
+ const timeoutError = new DOMException('signal timed out', 'TimeoutError');
199
+ globalThis.fetch = mockFetch(0, null, {
200
+ shouldThrow: true,
201
+ throwError: timeoutError,
202
+ });
203
+ const scout = createScout({ coreUrl: 'http://localhost:3000', timeout: 100 });
204
+
205
+ const result = await scout.ping();
206
+
207
+ assert.equal(result.status, 'error');
208
+ assert.equal(result.code, 'CORE_TIMEOUT');
209
+ });
210
+ });
211
+
212
+ describe('No prior initialisation', () => {
213
+ it('works without calling ready() first', async () => {
214
+ globalThis.fetch = mockFetch(200, HEALTHY_RESPONSE);
215
+ // Create scout but do NOT call ready()
216
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
217
+
218
+ const result = await scout.ping();
219
+
220
+ assert.equal(result.status, 'ok');
221
+ assert.equal(result.core.status, 'ready');
222
+ });
223
+ });
224
+
225
+ describe('No auth headers', () => {
226
+ it('does not send authentication headers', async () => {
227
+ let capturedHeaders = {};
228
+ globalThis.fetch = mock.fn(async (url, opts) => {
229
+ capturedHeaders = opts.headers || {};
230
+ return {
231
+ ok: true,
232
+ status: 200,
233
+ json: async () => HEALTHY_RESPONSE,
234
+ };
235
+ });
236
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
237
+
238
+ await scout.ping();
239
+
240
+ // Must NOT have auth headers
241
+ assert.equal(capturedHeaders['X-Agent-Id'], undefined);
242
+ assert.equal(capturedHeaders['X-Agent-Signature'], undefined);
243
+ assert.equal(capturedHeaders['X-Agent-Timestamp'], undefined);
244
+ assert.equal(capturedHeaders['Authorization'], undefined);
245
+
246
+ // MUST have correlation and SDK headers
247
+ assert.ok(capturedHeaders['X-Request-ID']);
248
+ assert.ok(capturedHeaders['X-Scout-SDK']);
249
+ });
250
+
251
+ it('calls the correct URL (coreUrl + /health/ready)', async () => {
252
+ let capturedUrl;
253
+ globalThis.fetch = mock.fn(async (url, opts) => {
254
+ capturedUrl = url;
255
+ return { ok: true, status: 200, json: async () => HEALTHY_RESPONSE };
256
+ });
257
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
258
+
259
+ await scout.ping();
260
+
261
+ assert.equal(capturedUrl, 'http://localhost:3000/health/ready');
262
+ });
263
+ });
264
+
265
+ describe('Concurrent calls', () => {
266
+ it('two concurrent ping() calls resolve independently', async () => {
267
+ let callCount = 0;
268
+ globalThis.fetch = mock.fn(async () => {
269
+ callCount++;
270
+ return { ok: true, status: 200, json: async () => HEALTHY_RESPONSE };
271
+ });
272
+ const scout = createScout({ coreUrl: 'http://localhost:3000' });
273
+
274
+ const [r1, r2] = await Promise.all([scout.ping(), scout.ping()]);
275
+
276
+ assert.equal(r1.status, 'ok');
277
+ assert.equal(r2.status, 'ok');
278
+ // Both should make their own fetch call (no deduplication)
279
+ assert.equal(callCount, 2);
280
+ });
281
+ });
282
+ });
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Protocol Test Runner
5
+ *
6
+ * Runs all protocol implementation tests:
7
+ * - MCP Client
8
+ * - AP2 Mandates
9
+ * - Visa TAP
10
+ *
11
+ * Usage:
12
+ * node src/tests/run-protocol-tests.js
13
+ *
14
+ * Or with Node's test runner directly:
15
+ * node --test src/tests/mcp-client.test.js src/tests/ap2-mandates.test.js src/tests/visa-tap.test.js
16
+ */
17
+
18
+ import { spawn } from 'child_process';
19
+ import { fileURLToPath } from 'url';
20
+ import { dirname, join } from 'path';
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+
25
+ const testFiles = [
26
+ 'mcp-client.test.js',
27
+ 'ap2-mandates.test.js',
28
+ 'visa-tap.test.js',
29
+ ];
30
+
31
+ console.log('\n' + '═'.repeat(70));
32
+ console.log(' AURA Scout SDK - Protocol Tests');
33
+ console.log('═'.repeat(70) + '\n');
34
+
35
+ console.log('Running tests for:');
36
+ console.log(' • MCP Client (Model Context Protocol)');
37
+ console.log(' • AP2 Mandates (Agent Payments Protocol)');
38
+ console.log(' • Visa TAP (Trusted Agent Protocol)');
39
+ console.log('');
40
+
41
+ const testPaths = testFiles.map(f => join(__dirname, f));
42
+
43
+ const child = spawn('node', ['--test', ...testPaths], {
44
+ stdio: 'inherit',
45
+ cwd: join(__dirname, '../..'),
46
+ });
47
+
48
+ child.on('exit', (code) => {
49
+ console.log('\n' + '═'.repeat(70));
50
+ if (code === 0) {
51
+ console.log(' ✅ All protocol tests passed!');
52
+ } else {
53
+ console.log(' ❌ Some tests failed');
54
+ }
55
+ console.log('═'.repeat(70) + '\n');
56
+ process.exit(code);
57
+ });