@nexvora/mcp-server 0.3.1 → 0.3.3

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.
Files changed (66) hide show
  1. package/README.md +15 -13
  2. package/dist/NexvoraClient.d.ts.map +1 -1
  3. package/dist/NexvoraClient.js +21 -3
  4. package/dist/NexvoraClient.js.map +1 -1
  5. package/dist/cli.d.ts +2 -2
  6. package/dist/cli.js +26 -20
  7. package/dist/cli.js.map +1 -1
  8. package/dist/createServer.d.ts +7 -0
  9. package/dist/createServer.d.ts.map +1 -1
  10. package/dist/createServer.js +3 -3
  11. package/dist/createServer.js.map +1 -1
  12. package/package.json +6 -2
  13. package/CHANGELOG.md +0 -208
  14. package/docs/setup/chatgpt-desktop.md +0 -120
  15. package/docs/setup/claude-code.md +0 -152
  16. package/docs/setup/cursor.md +0 -129
  17. package/src/NexvoraClient.ts +0 -328
  18. package/src/RateLimiter.ts +0 -74
  19. package/src/__tests__/NexvoraClient.test.ts +0 -424
  20. package/src/__tests__/RateLimiter.test.ts +0 -151
  21. package/src/__tests__/auth/oauth.test.ts +0 -246
  22. package/src/__tests__/cache.test.ts +0 -64
  23. package/src/__tests__/config.test.ts +0 -98
  24. package/src/__tests__/defineTool.test.ts +0 -223
  25. package/src/__tests__/fixtures/config.json +0 -7
  26. package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
  27. package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
  28. package/src/__tests__/integration/consulting.integration.test.ts +0 -213
  29. package/src/__tests__/integration/feed.integration.test.ts +0 -200
  30. package/src/__tests__/integration/helpers.ts +0 -118
  31. package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
  32. package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
  33. package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
  34. package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
  35. package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
  36. package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
  37. package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
  38. package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
  39. package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
  40. package/src/__tests__/nexvora_feed_post.test.ts +0 -147
  41. package/src/__tests__/nexvora_feed_react.test.ts +0 -98
  42. package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
  43. package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
  44. package/src/__tests__/nexvora_observatory.test.ts +0 -125
  45. package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
  46. package/src/auth/oauth.ts +0 -247
  47. package/src/cache.ts +0 -34
  48. package/src/cli.ts +0 -171
  49. package/src/config.ts +0 -70
  50. package/src/createServer.ts +0 -90
  51. package/src/defineTool.ts +0 -120
  52. package/src/index.ts +0 -36
  53. package/src/server/sse.ts +0 -149
  54. package/src/tools/nexvora_agentstack_answer.ts +0 -62
  55. package/src/tools/nexvora_agentstack_ask.ts +0 -70
  56. package/src/tools/nexvora_agentstack_search.ts +0 -82
  57. package/src/tools/nexvora_consulting_book.ts +0 -130
  58. package/src/tools/nexvora_consulting_search.ts +0 -85
  59. package/src/tools/nexvora_feed_post.ts +0 -69
  60. package/src/tools/nexvora_feed_react.ts +0 -48
  61. package/src/tools/nexvora_knowledge_search.ts +0 -81
  62. package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
  63. package/src/tools/nexvora_observatory.ts +0 -87
  64. package/src/tools/nexvora_submit_task.ts +0 -42
  65. package/src/tools/nexvora_wallet_balance.ts +0 -112
  66. package/tsconfig.json +0 -19
@@ -1,246 +0,0 @@
1
- /**
2
- * Unit tests for the OAuth 2.0 Device Authorization Grant login module.
3
- *
4
- * All network calls (fetch), the browser-opener (node:child_process.exec) and
5
- * the polling sleep are mocked so these tests run instantly without any real
6
- * network access or wall-clock waits.
7
- */
8
-
9
- import { jest } from "@jest/globals";
10
-
11
- // ── Mocks must be set up before importing the module under test ─────────────
12
-
13
- jest.unstable_mockModule("node:child_process", () => ({
14
- exec: jest.fn((_cmd: string, cb?: (err: Error | null) => void) => {
15
- cb?.(null);
16
- }),
17
- }));
18
-
19
- jest.unstable_mockModule("node:os", () => ({
20
- hostname: jest.fn(() => "test-host"),
21
- }));
22
-
23
- const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
24
- global.fetch = mockFetch as typeof fetch;
25
-
26
- // ── Now import the module under test ───────────────────────────────────────
27
-
28
- const { loginInteractive, DeviceGrantError } = await import("../../auth/oauth.js");
29
-
30
- // ── Helpers ────────────────────────────────────────────────────────────────
31
-
32
- function jsonResponse(data: unknown, status = 200): Response {
33
- return {
34
- ok: status >= 200 && status < 300,
35
- status,
36
- json: () => Promise.resolve(data),
37
- text: () => Promise.resolve(JSON.stringify(data)),
38
- } as unknown as Response;
39
- }
40
-
41
- function errorJsonResponse(status: number, body: unknown): Response {
42
- return {
43
- ok: false,
44
- status,
45
- json: () => Promise.resolve(body),
46
- text: () => Promise.resolve(JSON.stringify(body)),
47
- } as unknown as Response;
48
- }
49
-
50
- function networkErrorResponse(status: number, text = "error"): Response {
51
- return {
52
- ok: false,
53
- status,
54
- json: () => Promise.reject(new Error("not json")),
55
- text: () => Promise.resolve(text),
56
- } as unknown as Response;
57
- }
58
-
59
- const AUTHORIZE_RESPONSE = {
60
- device_code: "dev-code-abc",
61
- user_code: "ABCD-1234",
62
- verification_uri: "https://app.nxvora.online/oauth/device",
63
- verification_uri_complete: "https://app.nxvora.online/oauth/device?user_code=ABCD-1234",
64
- expires_in: 600,
65
- interval: 0, // 0 so the test doesn't actually wait between polls
66
- };
67
-
68
- const TOKEN_SUCCESS = {
69
- access_token: "access-token-abc",
70
- refresh_token: "refresh-token-xyz",
71
- token_type: "Bearer",
72
- expires_in: 3600,
73
- };
74
-
75
- const USER = { id: "user-123" };
76
-
77
- // ── loginInteractive ───────────────────────────────────────────────────────
78
-
79
- /**
80
- * Drive a loginInteractive() invocation past the polling sleeps by running
81
- * any scheduled timers and yielding control to fetch mocks until the promise
82
- * settles. Uses jest fake timers so the test does not sleep in real time.
83
- */
84
- async function runLoginToCompletion<T>(promise: Promise<T>): Promise<T> {
85
- // Loop while the promise is still pending. Each iteration: let queued
86
- // microtasks settle, advance fake timers (firing any pending setTimeout),
87
- // then yield again so the fetch mock can resolve.
88
- let settled = false;
89
- let result: T;
90
- let err: unknown;
91
- promise.then(
92
- (v) => { result = v; settled = true; },
93
- (e) => { err = e; settled = true; },
94
- );
95
- for (let i = 0; i < 50 && !settled; i++) {
96
- // eslint-disable-next-line no-await-in-loop
97
- await Promise.resolve();
98
- // eslint-disable-next-line no-await-in-loop
99
- await jest.advanceTimersByTimeAsync(10_000);
100
- }
101
- if (!settled) throw new Error("loginInteractive did not settle within timer budget");
102
- if (err) throw err;
103
- return result!;
104
- }
105
-
106
- describe("loginInteractive (Device Grant)", () => {
107
- beforeEach(() => {
108
- mockFetch.mockReset();
109
- jest.useFakeTimers();
110
- });
111
-
112
- afterEach(() => {
113
- jest.useRealTimers();
114
- });
115
-
116
- it("returns a Config when the first poll already succeeds", async () => {
117
- mockFetch
118
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
119
- .mockResolvedValueOnce(jsonResponse(TOKEN_SUCCESS))
120
- .mockResolvedValueOnce(jsonResponse(USER));
121
-
122
- const config = await runLoginToCompletion(loginInteractive("https://api.nxvora.online"));
123
-
124
- expect(config.accessToken).toBe("access-token-abc");
125
- expect(config.refreshToken).toBe("refresh-token-xyz");
126
- expect(config.serverUrl).toBe("https://api.nxvora.online");
127
- expect(config.userId).toBe("user-123");
128
- expect(config.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
129
- });
130
-
131
- it("calls /oauth/device/authorize with client_id, scope, device_name", async () => {
132
- mockFetch
133
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
134
- .mockResolvedValueOnce(jsonResponse(TOKEN_SUCCESS))
135
- .mockResolvedValueOnce(jsonResponse(USER));
136
-
137
- await runLoginToCompletion(loginInteractive("https://api.nxvora.online"));
138
-
139
- const [url, init] = mockFetch.mock.calls[0];
140
- expect(url).toBe("https://api.nxvora.online/oauth/device/authorize");
141
- const body = JSON.parse((init as RequestInit).body as string);
142
- expect(body.client_id).toBe("nexvora-mcp-server");
143
- expect(body.scope).toBe("mcp:tools offline_access");
144
- expect(body.device_name).toBe("test-host");
145
- });
146
-
147
- it("calls /oauth/device/token with the device_code URN grant type", async () => {
148
- mockFetch
149
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
150
- .mockResolvedValueOnce(jsonResponse(TOKEN_SUCCESS))
151
- .mockResolvedValueOnce(jsonResponse(USER));
152
-
153
- await runLoginToCompletion(loginInteractive("https://api.nxvora.online"));
154
-
155
- const [url, init] = mockFetch.mock.calls[1];
156
- expect(url).toBe("https://api.nxvora.online/oauth/device/token");
157
- const body = JSON.parse((init as RequestInit).body as string);
158
- expect(body.grant_type).toBe("urn:ietf:params:oauth:grant-type:device_code");
159
- expect(body.device_code).toBe("dev-code-abc");
160
- expect(body.client_id).toBe("nexvora-mcp-server");
161
- });
162
-
163
- it("keeps polling on authorization_pending until tokens are issued", async () => {
164
- mockFetch
165
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
166
- .mockResolvedValueOnce(errorJsonResponse(400, { error: "authorization_pending" }))
167
- .mockResolvedValueOnce(errorJsonResponse(400, { error: "authorization_pending" }))
168
- .mockResolvedValueOnce(jsonResponse(TOKEN_SUCCESS))
169
- .mockResolvedValueOnce(jsonResponse(USER));
170
-
171
- const config = await runLoginToCompletion(loginInteractive("https://api.nxvora.online"));
172
-
173
- expect(config.accessToken).toBe("access-token-abc");
174
- // 1 authorize + 3 token polls + 1 user-fetch = 5 calls
175
- expect(mockFetch).toHaveBeenCalledTimes(5);
176
- });
177
-
178
- it("honours slow_down by continuing to poll (interval bumped internally)", async () => {
179
- mockFetch
180
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
181
- .mockResolvedValueOnce(errorJsonResponse(400, { error: "slow_down" }))
182
- .mockResolvedValueOnce(jsonResponse(TOKEN_SUCCESS))
183
- .mockResolvedValueOnce(jsonResponse(USER));
184
-
185
- const config = await runLoginToCompletion(loginInteractive("https://api.nxvora.online"));
186
-
187
- expect(config.accessToken).toBe("access-token-abc");
188
- });
189
-
190
- it("throws DeviceGrantError(access_denied) when the user denies", async () => {
191
- mockFetch
192
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
193
- .mockResolvedValueOnce(errorJsonResponse(400, {
194
- error: "access_denied",
195
- error_description: "User denied the request",
196
- }));
197
-
198
- await expect(
199
- runLoginToCompletion(loginInteractive("https://api.nxvora.online")),
200
- ).rejects.toMatchObject({
201
- name: "DeviceGrantError",
202
- code: "access_denied",
203
- });
204
- });
205
-
206
- it("throws DeviceGrantError(expired_token) when the code expires", async () => {
207
- mockFetch
208
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
209
- .mockResolvedValueOnce(errorJsonResponse(400, { error: "expired_token" }));
210
-
211
- await expect(
212
- runLoginToCompletion(loginInteractive("https://api.nxvora.online")),
213
- ).rejects.toBeInstanceOf(DeviceGrantError);
214
- });
215
-
216
- it("throws a descriptive error when /oauth/device/authorize returns non-2xx", async () => {
217
- mockFetch.mockResolvedValueOnce(networkErrorResponse(404));
218
-
219
- await expect(
220
- runLoginToCompletion(loginInteractive("https://api.nxvora.online")),
221
- ).rejects.toThrow(/Failed to start OAuth device authorization \(404\)/);
222
- });
223
-
224
- it("falls back to empty string userId when /api/users/me returns non-2xx", async () => {
225
- mockFetch
226
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
227
- .mockResolvedValueOnce(jsonResponse(TOKEN_SUCCESS))
228
- .mockResolvedValueOnce(networkErrorResponse(401));
229
-
230
- const config = await runLoginToCompletion(loginInteractive("https://api.nxvora.online"));
231
- expect(config.userId).toBe("");
232
- });
233
-
234
- it("strips trailing slash from base URL", async () => {
235
- mockFetch
236
- .mockResolvedValueOnce(jsonResponse(AUTHORIZE_RESPONSE))
237
- .mockResolvedValueOnce(jsonResponse(TOKEN_SUCCESS))
238
- .mockResolvedValueOnce(jsonResponse(USER));
239
-
240
- const config = await runLoginToCompletion(loginInteractive("https://api.nxvora.online/"));
241
- expect(config.serverUrl).toBe("https://api.nxvora.online");
242
- expect(mockFetch.mock.calls[0][0]).toBe(
243
- "https://api.nxvora.online/oauth/device/authorize",
244
- );
245
- });
246
- });
@@ -1,64 +0,0 @@
1
- import { jest } from "@jest/globals";
2
-
3
- import { TtlCache } from "../cache.js";
4
-
5
- describe("TtlCache", () => {
6
- beforeEach(() => {
7
- jest.useFakeTimers();
8
- });
9
-
10
- afterEach(() => {
11
- jest.useRealTimers();
12
- });
13
-
14
- it("returns undefined for missing keys", () => {
15
- const cache = new TtlCache<string>(1000);
16
- expect(cache.get("missing")).toBeUndefined();
17
- });
18
-
19
- it("returns value within TTL", () => {
20
- const cache = new TtlCache<number>(5000);
21
- cache.set("key", 42);
22
- jest.advanceTimersByTime(4999);
23
- expect(cache.get("key")).toBe(42);
24
- });
25
-
26
- it("returns undefined after TTL expires", () => {
27
- const cache = new TtlCache<number>(1000);
28
- cache.set("key", 99);
29
- jest.advanceTimersByTime(1001);
30
- expect(cache.get("key")).toBeUndefined();
31
- });
32
-
33
- it("getOrFetch calls fn on cache miss", async () => {
34
- const cache = new TtlCache<string>(5000);
35
- const fn = jest.fn().mockResolvedValue("fetched");
36
-
37
- const result = await cache.getOrFetch("key", fn);
38
-
39
- expect(result).toBe("fetched");
40
- expect(fn).toHaveBeenCalledTimes(1);
41
- });
42
-
43
- it("getOrFetch returns cached value without calling fn on hit", async () => {
44
- const cache = new TtlCache<string>(5000);
45
- cache.set("key", "cached");
46
- const fn = jest.fn().mockResolvedValue("fetched");
47
-
48
- const result = await cache.getOrFetch("key", fn);
49
-
50
- expect(result).toBe("cached");
51
- expect(fn).not.toHaveBeenCalled();
52
- });
53
-
54
- it("getOrFetch calls fn again after TTL expires", async () => {
55
- const cache = new TtlCache<number>(1000);
56
- const fn = jest.fn().mockResolvedValue(1);
57
-
58
- await cache.getOrFetch("key", fn);
59
- jest.advanceTimersByTime(1001);
60
- await cache.getOrFetch("key", fn);
61
-
62
- expect(fn).toHaveBeenCalledTimes(2);
63
- });
64
- });
@@ -1,98 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
-
5
- import { ConfigManager, type Config } from "../config.js";
6
-
7
- const SAMPLE_CONFIG: Config = {
8
- accessToken: "access-abc",
9
- refreshToken: "refresh-xyz",
10
- expiresAt: 9999999999,
11
- serverUrl: "https://api.nxvora.online",
12
- userId: "user-001",
13
- };
14
-
15
- describe("ConfigManager", () => {
16
- let tmpDir: string;
17
- let configPath: string;
18
-
19
- beforeEach(() => {
20
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nexvora-config-test-"));
21
- configPath = path.join(tmpDir, "config.json");
22
- });
23
-
24
- afterEach(() => {
25
- fs.rmSync(tmpDir, { recursive: true, force: true });
26
- });
27
-
28
- describe("read()", () => {
29
- it("parses a valid config file", () => {
30
- fs.writeFileSync(configPath, JSON.stringify(SAMPLE_CONFIG), "utf8");
31
- const manager = new ConfigManager(configPath);
32
-
33
- const config = manager.read();
34
-
35
- expect(config.accessToken).toBe("access-abc");
36
- expect(config.refreshToken).toBe("refresh-xyz");
37
- expect(config.expiresAt).toBe(9999999999);
38
- expect(config.serverUrl).toBe("https://api.nxvora.online");
39
- expect(config.userId).toBe("user-001");
40
- });
41
-
42
- it("throws when file does not exist", () => {
43
- const manager = new ConfigManager(path.join(tmpDir, "nonexistent.json"));
44
- expect(() => manager.read()).toThrow();
45
- });
46
- });
47
-
48
- describe("write()", () => {
49
- it("writes config as pretty-printed JSON", () => {
50
- const manager = new ConfigManager(configPath);
51
- manager.write(SAMPLE_CONFIG);
52
-
53
- const raw = fs.readFileSync(configPath, "utf8");
54
- const parsed = JSON.parse(raw) as Config;
55
- expect(parsed).toEqual(SAMPLE_CONFIG);
56
- });
57
-
58
- it("creates parent directory if it does not exist", () => {
59
- const nestedPath = path.join(tmpDir, "nested", "dir", "config.json");
60
- const manager = new ConfigManager(nestedPath);
61
- manager.write(SAMPLE_CONFIG);
62
-
63
- expect(fs.existsSync(nestedPath)).toBe(true);
64
- });
65
-
66
- it("overwrites existing config file", () => {
67
- const manager = new ConfigManager(configPath);
68
- manager.write(SAMPLE_CONFIG);
69
-
70
- const updated = { ...SAMPLE_CONFIG, accessToken: "new-token" };
71
- manager.write(updated);
72
-
73
- const raw = fs.readFileSync(configPath, "utf8");
74
- expect(JSON.parse(raw).accessToken).toBe("new-token");
75
- });
76
-
77
- it("removes the tmp file after successful rename", () => {
78
- const manager = new ConfigManager(configPath);
79
- manager.write(SAMPLE_CONFIG);
80
-
81
- const tmpPath = `${configPath}.tmp`;
82
- expect(fs.existsSync(tmpPath)).toBe(false);
83
- });
84
-
85
- it("sets 0600 permissions on POSIX", () => {
86
- if (process.platform === "win32") return;
87
-
88
- const manager = new ConfigManager(configPath);
89
- manager.write(SAMPLE_CONFIG);
90
-
91
- const stat = fs.statSync(configPath);
92
- // eslint-disable-next-line no-bitwise
93
- const mode = stat.mode & 0o777;
94
- expect(mode).toBe(0o600);
95
- });
96
-
97
- });
98
- });
@@ -1,223 +0,0 @@
1
- import { jest } from "@jest/globals";
2
- import { z } from "zod";
3
-
4
- import { defineTool } from "../defineTool.js";
5
- import { NexvoraApiError, NexvoraClient } from "../NexvoraClient.js";
6
- import { RateLimiterRegistry } from "../RateLimiter.js";
7
-
8
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
- function makeClient(): any {
10
- const client = new NexvoraClient({
11
- baseUrl: "https://api.nxvora.online",
12
- accessToken: "token",
13
- agentId: "00000000-0000-0000-0000-000000000002",
14
- });
15
- (client as any).sendAudit = jest.fn().mockResolvedValue(undefined);
16
- return client;
17
- }
18
-
19
- describe("defineTool", () => {
20
- const echoTool = {
21
- name: "test_echo",
22
- description: "Echo tool",
23
- inputSchema: z.object({ message: z.string() }),
24
- handler: async (input: { message: string }) => `echo: ${input.message}`,
25
- };
26
-
27
- it("returns text content on success", async () => {
28
- const client = makeClient();
29
- const handler = defineTool(echoTool, client);
30
-
31
- const result = await handler({ message: "hello" });
32
-
33
- expect(result.isError).toBeFalsy();
34
- expect(result.content[0]?.text).toBe("echo: hello");
35
- });
36
-
37
- it("sends success audit on success", async () => {
38
- const client = makeClient();
39
- const handler = defineTool(echoTool, client);
40
-
41
- await handler({ message: "hello" });
42
-
43
- expect(client.sendAudit).toHaveBeenCalledWith(
44
- expect.objectContaining({ toolName: "test_echo", outcome: "success" }),
45
- );
46
- });
47
-
48
- it("returns error content and sends error audit on validation failure", async () => {
49
- const client = makeClient();
50
- const handler = defineTool(echoTool, client);
51
-
52
- const result = await handler({ message: 123 }); // wrong type
53
-
54
- expect(result.isError).toBe(true);
55
- expect(result.content[0]?.text).toContain("Invalid input");
56
- expect(client.sendAudit).toHaveBeenCalledWith(
57
- expect.objectContaining({ toolName: "test_echo", outcome: "error", errorCode: "VALIDATION_ERROR" }),
58
- );
59
- });
60
-
61
- it("sends rate_limited audit on 429 API error", async () => {
62
- const client = makeClient();
63
- const rateLimitedTool = {
64
- ...echoTool,
65
- handler: async () => {
66
- throw new NexvoraApiError(429, "Too Many Requests", "/tasks");
67
- },
68
- };
69
- const handler = defineTool(rateLimitedTool, client);
70
-
71
- const result = await handler({ message: "test" });
72
-
73
- expect(result.isError).toBe(true);
74
- expect(client.sendAudit).toHaveBeenCalledWith(
75
- expect.objectContaining({ outcome: "rate_limited" }),
76
- );
77
- });
78
-
79
- it("sends unauthorized audit on 401 API error", async () => {
80
- const client = makeClient();
81
- const unauthorizedTool = {
82
- ...echoTool,
83
- handler: async () => {
84
- throw new NexvoraApiError(401, "Unauthorized", "/tasks");
85
- },
86
- };
87
- const handler = defineTool(unauthorizedTool, client);
88
-
89
- const result = await handler({ message: "test" });
90
-
91
- expect(result.isError).toBe(true);
92
- expect(client.sendAudit).toHaveBeenCalledWith(
93
- expect.objectContaining({ outcome: "unauthorized" }),
94
- );
95
- });
96
-
97
- it("sends error audit on unexpected errors", async () => {
98
- const client = makeClient();
99
- const bugsyTool = {
100
- ...echoTool,
101
- handler: async () => {
102
- throw new Error("Something exploded");
103
- },
104
- };
105
- const handler = defineTool(bugsyTool, client);
106
-
107
- const result = await handler({ message: "test" });
108
-
109
- expect(result.isError).toBe(true);
110
- expect(result.content[0]?.text).toContain("Something exploded");
111
- expect(client.sendAudit).toHaveBeenCalledWith(
112
- expect.objectContaining({ outcome: "error", errorCode: "UNEXPECTED_ERROR" }),
113
- );
114
- });
115
-
116
- it("includes agentId from client in audit payload", async () => {
117
- const client = makeClient();
118
- const handler = defineTool(echoTool, client);
119
-
120
- await handler({ message: "test" });
121
-
122
- expect(client.sendAudit).toHaveBeenCalledWith(
123
- expect.objectContaining({ agentId: "00000000-0000-0000-0000-000000000002" }),
124
- );
125
- });
126
-
127
- it("includes durationMs in success audit", async () => {
128
- const client = makeClient();
129
- const handler = defineTool(echoTool, client);
130
-
131
- await handler({ message: "test" });
132
-
133
- const auditCall = client.sendAudit.mock.calls[0][0];
134
- expect(typeof auditCall.durationMs).toBe("number");
135
- expect(auditCall.durationMs).toBeGreaterThanOrEqual(0);
136
- });
137
-
138
- describe("rate limiting", () => {
139
- beforeEach(() => {
140
- jest.useFakeTimers({ now: 1_000_000_000_000 });
141
- });
142
- afterEach(() => {
143
- jest.useRealTimers();
144
- });
145
-
146
- it("allows calls when rate limiter has capacity", async () => {
147
- const client = makeClient();
148
- const rateLimiter = new RateLimiterRegistry({ test_echo: 10 });
149
- const handler = defineTool(echoTool, client, rateLimiter);
150
-
151
- const result = await handler({ message: "hello" });
152
-
153
- expect(result.isError).toBeFalsy();
154
- expect(result.content[0]?.text).toBe("echo: hello");
155
- });
156
-
157
- it("returns rate_limited error when bucket is exhausted", async () => {
158
- const client = makeClient();
159
- const rateLimiter = new RateLimiterRegistry({ test_echo: 1 });
160
- const handler = defineTool(echoTool, client, rateLimiter);
161
-
162
- await handler({ message: "first" }); // consumes the only token
163
- const result = await handler({ message: "second" }); // should be blocked
164
-
165
- expect(result.isError).toBe(true);
166
- expect(result.content[0]?.text).toContain("Rate limit exceeded");
167
- expect(result.content[0]?.text).toContain("test_echo");
168
- expect(result.content[0]?.text).toContain("Retry after");
169
- });
170
-
171
- it("sends rate_limited audit when bucket is exhausted", async () => {
172
- const client = makeClient();
173
- const rateLimiter = new RateLimiterRegistry({ test_echo: 1 });
174
- const handler = defineTool(echoTool, client, rateLimiter);
175
-
176
- await handler({ message: "first" });
177
- await handler({ message: "second" }); // blocked
178
-
179
- // second call should have sent a rate_limited audit
180
- const auditCalls = client.sendAudit.mock.calls as Array<[{ outcome: string }]>;
181
- const rateLimitedAudit = auditCalls.find(([payload]) => payload.outcome === "rate_limited");
182
- expect(rateLimitedAudit).toBeDefined();
183
- });
184
-
185
- it("does not invoke handler when rate limited", async () => {
186
- const client = makeClient();
187
- const rateLimiter = new RateLimiterRegistry({ test_echo: 1 });
188
- const handlerSpy = jest.fn().mockResolvedValue("result") as jest.MockedFunction<
189
- () => Promise<string>
190
- >;
191
- const spiedTool = { ...echoTool, handler: handlerSpy };
192
- const handler = defineTool(spiedTool, client, rateLimiter);
193
-
194
- await handler({ message: "first" }); // consumes token
195
- await handler({ message: "second" }); // blocked
196
-
197
- expect(handlerSpy).toHaveBeenCalledTimes(1);
198
- });
199
-
200
- it("allows calls again after bucket refills", async () => {
201
- const client = makeClient();
202
- const rateLimiter = new RateLimiterRegistry({ test_echo: 1 }); // 1/min
203
- const handler = defineTool(echoTool, client, rateLimiter);
204
-
205
- await handler({ message: "first" }); // consume
206
- const blocked = await handler({ message: "blocked" });
207
- expect(blocked.isError).toBe(true);
208
-
209
- jest.advanceTimersByTime(65_000); // advance past the 60s refill
210
-
211
- const allowed = await handler({ message: "after refill" });
212
- expect(allowed.isError).toBeFalsy();
213
- });
214
-
215
- it("proceeds normally with no rate limiter passed", async () => {
216
- const client = makeClient();
217
- const handler = defineTool(echoTool, client); // no rate limiter
218
-
219
- const result = await handler({ message: "unlimited" });
220
- expect(result.isError).toBeFalsy();
221
- });
222
- });
223
- });
@@ -1,7 +0,0 @@
1
- {
2
- "accessToken": "int-test-access-token",
3
- "refreshToken": "int-test-refresh-token",
4
- "expiresAt": 9999999999,
5
- "serverUrl": "https://api.nxvora.online",
6
- "userId": "int-test-user-001"
7
- }