@nexvora/mcp-server 0.3.2 → 0.4.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.
Files changed (69) 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.js +17 -11
  6. package/dist/cli.js.map +1 -1
  7. package/dist/createServer.d.ts +7 -0
  8. package/dist/createServer.d.ts.map +1 -1
  9. package/dist/createServer.js +3 -3
  10. package/dist/createServer.js.map +1 -1
  11. package/dist/tools/nexvora_submit_task.d.ts +7 -4
  12. package/dist/tools/nexvora_submit_task.d.ts.map +1 -1
  13. package/dist/tools/nexvora_submit_task.js +74 -4
  14. package/dist/tools/nexvora_submit_task.js.map +1 -1
  15. package/package.json +5 -1
  16. package/CHANGELOG.md +0 -208
  17. package/docs/setup/chatgpt-desktop.md +0 -120
  18. package/docs/setup/claude-code.md +0 -152
  19. package/docs/setup/cursor.md +0 -129
  20. package/src/NexvoraClient.ts +0 -328
  21. package/src/RateLimiter.ts +0 -74
  22. package/src/__tests__/NexvoraClient.test.ts +0 -424
  23. package/src/__tests__/RateLimiter.test.ts +0 -151
  24. package/src/__tests__/auth/oauth.test.ts +0 -246
  25. package/src/__tests__/cache.test.ts +0 -64
  26. package/src/__tests__/config.test.ts +0 -98
  27. package/src/__tests__/defineTool.test.ts +0 -223
  28. package/src/__tests__/fixtures/config.json +0 -7
  29. package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
  30. package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
  31. package/src/__tests__/integration/consulting.integration.test.ts +0 -213
  32. package/src/__tests__/integration/feed.integration.test.ts +0 -200
  33. package/src/__tests__/integration/helpers.ts +0 -118
  34. package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
  35. package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
  36. package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
  37. package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
  38. package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
  39. package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
  40. package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
  41. package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
  42. package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
  43. package/src/__tests__/nexvora_feed_post.test.ts +0 -147
  44. package/src/__tests__/nexvora_feed_react.test.ts +0 -98
  45. package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
  46. package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
  47. package/src/__tests__/nexvora_observatory.test.ts +0 -125
  48. package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
  49. package/src/auth/oauth.ts +0 -247
  50. package/src/cache.ts +0 -34
  51. package/src/cli.ts +0 -171
  52. package/src/config.ts +0 -70
  53. package/src/createServer.ts +0 -90
  54. package/src/defineTool.ts +0 -120
  55. package/src/index.ts +0 -36
  56. package/src/server/sse.ts +0 -149
  57. package/src/tools/nexvora_agentstack_answer.ts +0 -62
  58. package/src/tools/nexvora_agentstack_ask.ts +0 -70
  59. package/src/tools/nexvora_agentstack_search.ts +0 -82
  60. package/src/tools/nexvora_consulting_book.ts +0 -130
  61. package/src/tools/nexvora_consulting_search.ts +0 -85
  62. package/src/tools/nexvora_feed_post.ts +0 -69
  63. package/src/tools/nexvora_feed_react.ts +0 -48
  64. package/src/tools/nexvora_knowledge_search.ts +0 -81
  65. package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
  66. package/src/tools/nexvora_observatory.ts +0 -87
  67. package/src/tools/nexvora_submit_task.ts +0 -42
  68. package/src/tools/nexvora_wallet_balance.ts +0 -112
  69. 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
- }