@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.
- package/README.md +15 -13
- package/dist/NexvoraClient.d.ts.map +1 -1
- package/dist/NexvoraClient.js +21 -3
- package/dist/NexvoraClient.js.map +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +26 -20
- package/dist/cli.js.map +1 -1
- package/dist/createServer.d.ts +7 -0
- package/dist/createServer.d.ts.map +1 -1
- package/dist/createServer.js +3 -3
- package/dist/createServer.js.map +1 -1
- package/package.json +6 -2
- package/CHANGELOG.md +0 -208
- package/docs/setup/chatgpt-desktop.md +0 -120
- package/docs/setup/claude-code.md +0 -152
- package/docs/setup/cursor.md +0 -129
- package/src/NexvoraClient.ts +0 -328
- package/src/RateLimiter.ts +0 -74
- package/src/__tests__/NexvoraClient.test.ts +0 -424
- package/src/__tests__/RateLimiter.test.ts +0 -151
- package/src/__tests__/auth/oauth.test.ts +0 -246
- package/src/__tests__/cache.test.ts +0 -64
- package/src/__tests__/config.test.ts +0 -98
- package/src/__tests__/defineTool.test.ts +0 -223
- package/src/__tests__/fixtures/config.json +0 -7
- package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
- package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
- package/src/__tests__/integration/consulting.integration.test.ts +0 -213
- package/src/__tests__/integration/feed.integration.test.ts +0 -200
- package/src/__tests__/integration/helpers.ts +0 -118
- package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
- package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
- package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
- package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
- package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
- package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
- package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
- package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
- package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
- package/src/__tests__/nexvora_feed_post.test.ts +0 -147
- package/src/__tests__/nexvora_feed_react.test.ts +0 -98
- package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
- package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
- package/src/__tests__/nexvora_observatory.test.ts +0 -125
- package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
- package/src/auth/oauth.ts +0 -247
- package/src/cache.ts +0 -34
- package/src/cli.ts +0 -171
- package/src/config.ts +0 -70
- package/src/createServer.ts +0 -90
- package/src/defineTool.ts +0 -120
- package/src/index.ts +0 -36
- package/src/server/sse.ts +0 -149
- package/src/tools/nexvora_agentstack_answer.ts +0 -62
- package/src/tools/nexvora_agentstack_ask.ts +0 -70
- package/src/tools/nexvora_agentstack_search.ts +0 -82
- package/src/tools/nexvora_consulting_book.ts +0 -130
- package/src/tools/nexvora_consulting_search.ts +0 -85
- package/src/tools/nexvora_feed_post.ts +0 -69
- package/src/tools/nexvora_feed_react.ts +0 -48
- package/src/tools/nexvora_knowledge_search.ts +0 -81
- package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
- package/src/tools/nexvora_observatory.ts +0 -87
- package/src/tools/nexvora_submit_task.ts +0 -42
- package/src/tools/nexvora_wallet_balance.ts +0 -112
- 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
|
-
});
|